├── .expeditor ├── config.yml ├── run_linux_tests.sh ├── run_windows_tests.ps1 ├── update_version.sh └── verify.pipeline.yml ├── .github ├── CODEOWNERS └── ISSUE_TEMPLATE │ ├── BUG_TEMPLATE.md │ ├── DESIGN_PROPOSAL.md │ ├── ENHANCEMENT_REQUEST_TEMPLATE.md │ └── SUPPORT_QUESTION.md ├── .gitignore ├── .rspec ├── .rubocop.yml ├── CHANGELOG.md ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── Gemfile ├── LICENSE ├── README.md ├── Rakefile ├── VERSION ├── lib └── mixlib │ ├── shellout.rb │ └── shellout │ ├── exceptions.rb │ ├── helper.rb │ ├── unix.rb │ ├── version.rb │ ├── windows.rb │ └── windows │ └── core_ext.rb ├── mixlib-shellout-universal-mingw-ucrt.gemspec ├── mixlib-shellout.gemspec └── spec ├── mixlib ├── shellout │ ├── helper_spec.rb │ └── windows_spec.rb └── shellout_spec.rb ├── spec_helper.rb └── support ├── dependency_helper.rb └── platform_helpers.rb /.expeditor/config.yml: -------------------------------------------------------------------------------- 1 | # Documentation available at https://expeditor.chef.io/docs/getting-started/ 2 | --- 3 | 4 | # Slack channel in Chef Software slack to send notifications about build failures, etc 5 | slack: 6 | notify_channel: chef-found-notify 7 | 8 | # This publish is triggered by the `built_in:publish_rubygems` artifact_action. 9 | rubygems: 10 | - mixlib-shellout 11 | - mixlib-shellout-universal-mingw32 12 | - mixlib-shellout-universal-mingw-ucrt 13 | 14 | github: 15 | # This deletes the GitHub PR branch after successfully merged into the release branch 16 | delete_branch_on_merge: true 17 | # The tag format to use (e.g. v1.0.0) 18 | version_tag_format: "v{{version}}" 19 | # allow bumping the minor release via label 20 | minor_bump_labels: 21 | - "Expeditor: Bump Version Minor" 22 | # allow bumping the major release via label 23 | major_bump_labels: 24 | - "Expeditor: Bump Version Major" 25 | 26 | changelog: 27 | rollup_header: Changes not yet released to rubygems.org 28 | 29 | subscriptions: 30 | # These actions are taken, in order they are specified, anytime a Pull Request is merged. 31 | - workload: pull_request_merged:{{github_repo}}:{{release_branch}}:* 32 | actions: 33 | - built_in:bump_version: 34 | ignore_labels: 35 | - "Expeditor: Skip Version Bump" 36 | - "Expeditor: Skip All" 37 | - bash:.expeditor/update_version.sh: 38 | only_if: built_in:bump_version 39 | - built_in:update_changelog: 40 | ignore_labels: 41 | - "Expeditor: Skip Changelog" 42 | - "Expeditor: Skip All" 43 | - built_in:build_gem: 44 | only_if: built_in:bump_version 45 | - workload: project_promoted:{{agent_id}}:* 46 | actions: 47 | - built_in:rollover_changelog 48 | - built_in:publish_rubygems 49 | 50 | pipelines: 51 | - verify: 52 | description: Pull Request validation tests 53 | public: true 54 | -------------------------------------------------------------------------------- /.expeditor/run_linux_tests.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # 3 | # This script runs a passed in command, but first setups up the bundler caching on the repo 4 | 5 | set -ue 6 | 7 | export USER="root" 8 | export LANG=C.UTF-8 LANGUAGE=C.UTF-8 9 | 10 | echo "--- bundle install" 11 | bundle config --local path vendor/bundle 12 | bundle install --jobs=7 --retry=3 13 | 14 | echo "--- Running Cookstyle" 15 | gem install cookstyle 16 | cookstyle --chefstyle -c .rubocop.yml 17 | 18 | echo "+++ bundle exec task" 19 | bundle exec $@ 20 | -------------------------------------------------------------------------------- /.expeditor/run_windows_tests.ps1: -------------------------------------------------------------------------------- 1 | # Stop script execution when a non-terminating error occurs 2 | $ErrorActionPreference = "Stop" 3 | 4 | # This will run ruby test on windows platform 5 | 6 | Write-Output "--- Bundle install" 7 | 8 | bundle config --local path vendor/bundle 9 | If ($lastexitcode -ne 0) { Exit $lastexitcode } 10 | 11 | bundle install --jobs=7 --retry=3 12 | If ($lastexitcode -ne 0) { Exit $lastexitcode } 13 | 14 | Write-Output "--- Running Cookstyle" 15 | gem install cookstyle 16 | cookstyle --chefstyle -c .rubocop.yml 17 | If ($lastexitcode -ne 0) { Exit $lastexitcode } 18 | 19 | Write-Output "--- Bundle Execute" 20 | 21 | bundle exec rake 22 | If ($lastexitcode -ne 0) { Exit $lastexitcode } -------------------------------------------------------------------------------- /.expeditor/update_version.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | # 3 | # After a PR merge, Chef Expeditor will bump the PATCH version in the VERSION file. 4 | # It then executes this file to update any other files/components with that new version. 5 | # 6 | 7 | set -evx 8 | 9 | VERSION=$(cat VERSION) 10 | 11 | sed -i -r "s/^(\\s*)VERSION = \".+\"/\\1VERSION = \"$VERSION\"/" lib/mixlib/shellout/version.rb 12 | 13 | # Once Expeditor finshes executing this script, it will commit the changes and push 14 | # the commit as a new tag corresponding to the value in the VERSION file. 15 | -------------------------------------------------------------------------------- /.expeditor/verify.pipeline.yml: -------------------------------------------------------------------------------- 1 | --- 2 | expeditor: 3 | cached_folders: 4 | - vendor 5 | defaults: 6 | buildkite: 7 | retry: 8 | automatic: 9 | limit: 1 10 | timeout_in_minutes: 60 11 | 12 | steps: 13 | 14 | - label: run-lint-and-specs-ruby-3.1 15 | command: 16 | - .expeditor/run_linux_tests.sh rake 17 | expeditor: 18 | executor: 19 | docker: 20 | image: ruby:3.1 21 | 22 | - label: run-specs-ruby-3.1-windows 23 | command: 24 | - .expeditor/run_windows_tests.ps1 25 | expeditor: 26 | executor: 27 | docker: 28 | host_os: windows 29 | shell: ["powershell", "-Command"] 30 | image: rubydistros/windows-2019:3.1 31 | user: "NT AUTHORITY\\SYSTEM" 32 | 33 | - label: run-lint-and-specs-ruby-3.4 34 | command: 35 | - .expeditor/run_linux_tests.sh rake 36 | expeditor: 37 | executor: 38 | docker: 39 | image: ruby:3.4 40 | 41 | - label: run-specs-ruby-3.4-windows 42 | command: 43 | - .expeditor/run_windows_tests.ps1 44 | expeditor: 45 | executor: 46 | docker: 47 | host_os: windows 48 | shell: ["powershell", "-Command"] 49 | image: rubydistros/windows-2019:3.4 50 | user: "NT AUTHORITY\\SYSTEM" 51 | -------------------------------------------------------------------------------- /.github/CODEOWNERS: -------------------------------------------------------------------------------- 1 | # Order is important. The last matching pattern has the most precedence. 2 | 3 | * @chef/chef-infra-reviewers @chef/chef-infra-approvers @chef/chef-infra-owners @jaymzh 4 | .expeditor/ @chef/infra-packages 5 | *.md @chef/docs-team 6 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/BUG_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: � Bug Report 3 | about: If something isn't working as expected �. 4 | labels: "Status: Untriaged, Type: Bug" 5 | --- 6 | 7 | # Version: 8 | 9 | [Version of the project installed] 10 | 11 | # Environment: 12 | 13 | [Details about the environment such as the Operating System, cookbook details, etc...] 14 | 15 | # Scenario: 16 | 17 | [What you are trying to achieve and you can't?] 18 | 19 | # Steps to Reproduce: 20 | 21 | [If you are filing an issue what are the things we need to do in order to repro your problem?] 22 | 23 | # Expected Result: 24 | 25 | [What are you expecting to happen as the consequence of above reproduction steps?] 26 | 27 | # Actual Result: 28 | 29 | [What actually happens after the reproduction steps?] 30 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/DESIGN_PROPOSAL.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Design Proposal 3 | about: I have a significant change I would like to propose and discuss before starting 4 | labels: "Status: Untriaged, Type: Design Proposal" 5 | --- 6 | 7 | ### When a Change Needs a Design Proposal 8 | 9 | A design proposal should be opened any time a change meets one of the following qualifications: 10 | 11 | - Significantly changes the user experience of a project in a way that impacts users. 12 | - Significantly changes the underlying architecture of the project in a way that impacts other developers. 13 | - Changes the development or testing process of the project such as a change of CI systems or test frameworks. 14 | 15 | ### Why We Use This Process 16 | 17 | - Allows all interested parties (including any community member) to discuss large impact changes to a project. 18 | - Serves as a durable paper trail for discussions regarding project architecture. 19 | - Forces design discussions to occur before PRs are created. 20 | - Reduces PR refactoring and rejected PRs. 21 | 22 | --- 23 | 24 | 25 | 26 | ## Motivation 27 | 28 | 33 | 34 | ## Specification 35 | 36 | 37 | 38 | ## Downstream Impact 39 | 40 | 41 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/ENHANCEMENT_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: 🚀 Enhancement Request 3 | about: I have a suggestion (and may want to implement it 🙂)! 4 | labels: "Status: Untriaged" 5 | --- 6 | 7 | ### Describe the Enhancement: 8 | 9 | 10 | ### Describe the Need: 11 | 12 | 13 | ### Current Alternative 14 | 15 | 16 | ### Can We Help You Implement This?: 17 | 18 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/SUPPORT_QUESTION.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: 🤗 Support Question 3 | about: If you have a question 💬, please check out our Slack! 4 | --- 5 | 6 | We use GitHub issues to track bugs and feature requests. If you need help please post to our Mailing List or join the Chef Community Slack. 7 | 8 | * Chef Community Slack at http://community-slack.chef.io/. 9 | * Chef Mailing List https://discourse.chef.io/ 10 | 11 | Support issues opened here will be closed and redirected to Slack or Discourse. 12 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .autotest 2 | coverage 3 | doc 4 | .DS_Store 5 | pkg 6 | *.swp 7 | *.swo 8 | erl_crash.dump 9 | *.rake_tasks~ 10 | .idea 11 | *.rbc 12 | .rvmrc 13 | .bundle 14 | Gemfile.lock 15 | */tags 16 | *~ 17 | vendor/ -------------------------------------------------------------------------------- /.rspec: -------------------------------------------------------------------------------- 1 | -f documentation --color 2 | -------------------------------------------------------------------------------- /.rubocop.yml: -------------------------------------------------------------------------------- 1 | AllCops: 2 | TargetRubyVersion: 3.1 3 | 4 | Lint/UnderscorePrefixedVariableName: 5 | Exclude: 6 | - 'spec/mixlib/shellout/windows_spec.rb' 7 | 8 | # Configuration parameters: ContextCreatingMethods. 9 | Lint/UselessAccessModifier: 10 | Exclude: 11 | - 'lib/mixlib/shellout/windows/core_ext.rb' -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # mixlib-shellout Changelog 2 | 3 | 4 | ## [v3.4.3](https://github.com/chef/mixlib-shellout/tree/v3.4.3) (2025-05-12) 5 | 6 | #### Merged Pull Requests 7 | - add myself to codeowners [#260](https://github.com/chef/mixlib-shellout/pull/260) ([jaymzh](https://github.com/jaymzh)) 8 | 9 | 10 | ### Changes not yet released to rubygems.org 11 | 12 | #### Merged Pull Requests 13 | - add myself to codeowners [#260](https://github.com/chef/mixlib-shellout/pull/260) ([jaymzh](https://github.com/jaymzh)) 14 | - assign @stdout, @stderr and @process_status to mutable strings in preparation for ruby 4.0 [#265](https://github.com/chef/mixlib-shellout/pull/265) ([rishichawda](https://github.com/rishichawda)) 15 | - add logger as an explicit dependency [#266](https://github.com/chef/mixlib-shellout/pull/266) ([rishichawda](https://github.com/rishichawda)) 16 | - update readme.md [#264](https://github.com/chef/mixlib-shellout/pull/264) ([rishichawda](https://github.com/rishichawda)) 17 | - attempting to fix the bundler error [#263](https://github.com/chef/mixlib-shellout/pull/263) ([rishichawda](https://github.com/rishichawda)) 18 | - ruby 3.4 upgrade [#261](https://github.com/chef/mixlib-shellout/pull/261) ([rishichawda](https://github.com/rishichawda)) 19 | - Adding cookstyle to the windows tester [#262](https://github.com/chef/mixlib-shellout/pull/262) ([johnmccrae](https://github.com/johnmccrae)) 20 | - migrating to cookstyle from chefstyle [#258](https://github.com/chef/mixlib-shellout/pull/258) ([johnmccrae](https://github.com/johnmccrae)) 21 | 22 | 23 | ## [v3.3.9](https://github.com/chef/mixlib-shellout/tree/v3.3.9) (2025-04-04) 24 | 25 | #### Merged Pull Requests 26 | - Drop the FFI pin [#259](https://github.com/chef/mixlib-shellout/pull/259) ([jaymzh](https://github.com/jaymzh)) 27 | 28 | 29 | ## [v3.3.8](https://github.com/chef/mixlib-shellout/tree/v3.3.8) (2025-03-04) 30 | 31 | #### Merged Pull Requests 32 | - Set timeout to zero for last select (#245) [#255](https://github.com/chef/mixlib-shellout/pull/255) ([dafyddcrosby](https://github.com/dafyddcrosby)) 33 | - Add cgroupv2 support for linux shellouts [#256](https://github.com/chef/mixlib-shellout/pull/256) ([ImanolBarba](https://github.com/ImanolBarba)) 34 | 35 | ## [v3.3.6](https://github.com/chef/mixlib-shellout/tree/v3.3.6) (2025-01-16) 36 | 37 | #### Merged Pull Requests 38 | - [CI] Drop EOL Rubies [#253](https://github.com/chef/mixlib-shellout/pull/253) ([dafyddcrosby](https://github.com/dafyddcrosby)) 39 | - Fix quoting regression [#254](https://github.com/chef/mixlib-shellout/pull/254) ([thheinen](https://github.com/thheinen)) 40 | 41 | ## [v3.3.4](https://github.com/chef/mixlib-shellout/tree/v3.3.4) (2024-11-05) 42 | 43 | #### Merged Pull Requests 44 | - Fix quoting of command arguments in Target Mode [#251](https://github.com/chef/mixlib-shellout/pull/251) ([thheinen](https://github.com/thheinen)) 45 | 46 | ## [v3.3.3](https://github.com/chef/mixlib-shellout/tree/v3.3.3) (2024-10-14) 47 | 48 | ## [v3.3.3](https://github.com/chef/mixlib-shellout/tree/v3.3.3) (2024-10-14) 49 | 50 | #### Merged Pull Requests 51 | - [Unix#run_command] Remove Ruby 1.8.7 check [#242](https://github.com/chef/mixlib-shellout/pull/242) ([dafyddcrosby](https://github.com/dafyddcrosby)) 52 | - [#239] Add execution time to Windows shellout object [#247](https://github.com/chef/mixlib-shellout/pull/247) ([dafyddcrosby](https://github.com/dafyddcrosby)) 53 | - Increase EPIPE test input size based on platform page size [#241](https://github.com/chef/mixlib-shellout/pull/241) ([matoro](https://github.com/matoro)) 54 | - Adjustments for Chef Target Mode [#243](https://github.com/chef/mixlib-shellout/pull/243) ([thheinen](https://github.com/thheinen)) 55 | - Migrate from Chefstyle to Cookstyle [#249](https://github.com/chef/mixlib-shellout/pull/249) ([dafyddcrosby](https://github.com/dafyddcrosby)) 56 | - Fix execution in target mode with cwd parameter given [#250](https://github.com/chef/mixlib-shellout/pull/250) ([thheinen](https://github.com/thheinen)) 57 | - Fix execution of multiline inputs to target mode; Improve error output [#248](https://github.com/chef/mixlib-shellout/pull/248) ([thheinen](https://github.com/thheinen)) 58 | 59 | ## [v3.2.8](https://github.com/chef/mixlib-shellout/tree/v3.2.8) (2024-06-11) 60 | 61 | #### Merged Pull Requests 62 | - Keep ffi version below 1.17 [#244](https://github.com/chef/mixlib-shellout/pull/244) ([tpowell-progress](https://github.com/tpowell-progress)) 63 | 64 | ## [v3.2.7](https://github.com/chef/mixlib-shellout/tree/v3.2.7) (2022-04-04) 65 | 66 | ## [v3.2.7](https://github.com/chef/mixlib-shellout/tree/v3.2.7) (2022-04-04) 67 | 68 | #### Merged Pull Requests 69 | - Loosen platform regex to allow 64 or 32 bit mingw [#234](https://github.com/chef/mixlib-shellout/pull/234) ([clintoncwolfe](https://github.com/clintoncwolfe)) 70 | - Hard Coding the gems in the gemfile to overcome a Ruby 3.1 bug [#235](https://github.com/chef/mixlib-shellout/pull/235) ([johnmccrae](https://github.com/johnmccrae)) 71 | 72 | ## [v3.2.6](https://github.com/chef/mixlib-shellout/tree/v3.2.6) (2022-03-31) 73 | 74 | #### Merged Pull Requests 75 | - Loosen platform regex to allow 64 or 32 bit mingw [#234](https://github.com/chef/mixlib-shellout/pull/234) ([clintoncwolfe](https://github.com/clintoncwolfe)) 76 | 77 | ## [v3.2.5](https://github.com/chef/mixlib-shellout/tree/v3.2.5) (2021-02-13) 78 | 79 | #### Merged Pull Requests 80 | - fix broken windows tests [#227](https://github.com/chef/mixlib-shellout/pull/227) ([mwrock](https://github.com/mwrock)) 81 | - Add Ruby 3 testing + cleanup deps [#228](https://github.com/chef/mixlib-shellout/pull/228) ([tas50](https://github.com/tas50)) 82 | - gemspec: add license metadata [#229](https://github.com/chef/mixlib-shellout/pull/229) ([priv-kweihmann](https://github.com/priv-kweihmann)) 83 | 84 | ## [v3.2.2](https://github.com/chef/mixlib-shellout/tree/v3.2.2) (2020-11-16) 85 | 86 | #### Merged Pull Requests 87 | - Remove copyright dates [#225](https://github.com/chef/mixlib-shellout/pull/225) ([tas50](https://github.com/tas50)) 88 | - Cleanup deps and fix the failing spec helper loading of support files [#226](https://github.com/chef/mixlib-shellout/pull/226) ([tas50](https://github.com/tas50)) 89 | 90 | ## [v3.2.0](https://github.com/chef/mixlib-shellout/tree/v3.2.0) (2020-11-12) 91 | 92 | #### Merged Pull Requests 93 | - Windows: fetch env variables for specified users [#224](https://github.com/chef/mixlib-shellout/pull/224) ([kapilchouhan99](https://github.com/kapilchouhan99)) 94 | 95 | ## [v3.1.7](https://github.com/chef/mixlib-shellout/tree/v3.1.7) (2020-10-29) 96 | 97 | #### Merged Pull Requests 98 | - Loosen win32-process dep to resolve Ruby 3 deprecation warnings [#223](https://github.com/chef/mixlib-shellout/pull/223) ([tas50](https://github.com/tas50)) 99 | 100 | ## [v3.1.6](https://github.com/chef/mixlib-shellout/tree/v3.1.6) (2020-09-10) 101 | 102 | #### Merged Pull Requests 103 | - Use __dir__ instead of __FILE__ [#220](https://github.com/chef/mixlib-shellout/pull/220) ([tas50](https://github.com/tas50)) 104 | - Simplify things a bit with &. [#221](https://github.com/chef/mixlib-shellout/pull/221) ([tas50](https://github.com/tas50)) 105 | 106 | ## [v3.1.4](https://github.com/chef/mixlib-shellout/tree/v3.1.4) (2020-08-13) 107 | 108 | #### Merged Pull Requests 109 | - Fix a few typos [#217](https://github.com/chef/mixlib-shellout/pull/217) ([tas50](https://github.com/tas50)) 110 | - Optimize requires for non-omnibus installs [#218](https://github.com/chef/mixlib-shellout/pull/218) ([tas50](https://github.com/tas50)) 111 | 112 | ## [v3.1.2](https://github.com/chef/mixlib-shellout/tree/v3.1.2) (2020-07-24) 113 | 114 | #### Merged Pull Requests 115 | - convert helper to default_paths API [#216](https://github.com/chef/mixlib-shellout/pull/216) ([lamont-granquist](https://github.com/lamont-granquist)) 116 | 117 | ## [v3.1.1](https://github.com/chef/mixlib-shellout/tree/v3.1.1) (2020-07-18) 118 | 119 | ## [v3.1.0](https://github.com/chef/mixlib-shellout/tree/v3.1.0) (2020-07-17) 120 | 121 | #### Merged Pull Requests 122 | - shellout_spec: make "current user" independent of the environment [#203](https://github.com/chef/mixlib-shellout/pull/203) ([terceiro](https://github.com/terceiro)) 123 | - Minor doc fixes [#205](https://github.com/chef/mixlib-shellout/pull/205) ([phiggins](https://github.com/phiggins)) 124 | - extracting shell_out helper to mixlib-shellout [#206](https://github.com/chef/mixlib-shellout/pull/206) ([lamont-granquist](https://github.com/lamont-granquist)) 125 | - Bumping minor version [#207](https://github.com/chef/mixlib-shellout/pull/207) ([lamont-granquist](https://github.com/lamont-granquist)) 126 | - Test on Ruby 2.7 final, update chefstyle, and other CI fixes [#208](https://github.com/chef/mixlib-shellout/pull/208) ([tas50](https://github.com/tas50)) 127 | - Bump minor for release [#210](https://github.com/chef/mixlib-shellout/pull/210) ([lamont-granquist](https://github.com/lamont-granquist)) 128 | - Bumping minor for release again, again. [#211](https://github.com/chef/mixlib-shellout/pull/211) ([lamont-granquist](https://github.com/lamont-granquist)) 129 | 130 | ## [v3.0.9](https://github.com/chef/mixlib-shellout/tree/v3.0.9) (2019-12-30) 131 | 132 | #### Merged Pull Requests 133 | - Add Ruby 2.6/2.7 and Windows testing [#198](https://github.com/chef/mixlib-shellout/pull/198) ([tas50](https://github.com/tas50)) 134 | - Substitute require for require_relative [#199](https://github.com/chef/mixlib-shellout/pull/199) ([tas50](https://github.com/tas50)) 135 | 136 | ## [3.0.7](https://github.com/chef/mixlib-shellout/tree/3.0.7) (2019-07-31) 137 | 138 | #### Merged Pull Requests 139 | - Add the actual BK pipeline config [#185](https://github.com/chef/mixlib-shellout/pull/185) ([tas50](https://github.com/tas50)) 140 | - Blinding applying chefstyle -a. [#191](https://github.com/chef/mixlib-shellout/pull/191) ([zenspider](https://github.com/zenspider)) 141 | - Fix return type of Process.create to be a ProcessInfo instance again. [#190](https://github.com/chef/mixlib-shellout/pull/190) ([zenspider](https://github.com/zenspider)) 142 | 143 | ## [v3.0.4](https://github.com/chef/mixlib-shellout/tree/v3.0.4) (2019-06-07) 144 | 145 | #### Merged Pull Requests 146 | - update travis/appveyor, drop ruby 2.2 support, test on 2.6 [#176](https://github.com/chef/mixlib-shellout/pull/176) ([lamont-granquist](https://github.com/lamont-granquist)) 147 | - Misnamed parameter in README [#178](https://github.com/chef/mixlib-shellout/pull/178) ([martinisoft](https://github.com/martinisoft)) 148 | - Add new github templates and codeowners file [#179](https://github.com/chef/mixlib-shellout/pull/179) ([tas50](https://github.com/tas50)) 149 | - Add BuildKite pipeline [#184](https://github.com/chef/mixlib-shellout/pull/184) ([tas50](https://github.com/tas50)) 150 | - Support array args on windows WIP [#182](https://github.com/chef/mixlib-shellout/pull/182) ([lamont-granquist](https://github.com/lamont-granquist)) 151 | - Load and unload user profile as required [#177](https://github.com/chef/mixlib-shellout/pull/177) ([dayglojesus](https://github.com/dayglojesus)) 152 | 153 | ## [v2.4.4](https://github.com/chef/mixlib-shellout/tree/v2.4.4) (2018-12-12) 154 | 155 | #### Merged Pull Requests 156 | - Have expeditor promote the windows gem as well [#172](https://github.com/chef/mixlib-shellout/pull/172) ([tas50](https://github.com/tas50)) 157 | - Don't ship the readme in the gem artifact [#173](https://github.com/chef/mixlib-shellout/pull/173) ([tas50](https://github.com/tas50)) 158 | 159 | ## [v2.4.2](https://github.com/chef/mixlib-shellout/tree/v2.4.2) (2018-12-06) 160 | 161 | #### Merged Pull Requests 162 | - Test on ruby-head and Ruby 2.6 in Travis [#170](https://github.com/chef/mixlib-shellout/pull/170) ([tas50](https://github.com/tas50)) 163 | - Remove dev deps from the gemspec [#171](https://github.com/chef/mixlib-shellout/pull/171) ([tas50](https://github.com/tas50)) 164 | 165 | ## Release 2.4.0 166 | 167 | - Added username and password validation for elevated option on Windows 168 | - Added support for setting sensitive so that potentially sensitive output is suppressed 169 | 170 | ## Release 2.3.2 171 | 172 | - Fix bad method call in Windows Process module 173 | 174 | ## Release 2.3.1 175 | 176 | - Make Mixlib::ShellOut::EmptyWindowsCommand inherit from ShellCommandFailed 177 | 178 | ## Release 2.3.0 179 | 180 | - Add support for 'elevated' option on Windows, which logs on as batch server which is not affected by User Account Control (UAC) 181 | 182 | ## Release 2.2.6 183 | 184 | - Fix regression introduced in 2.2.2 by changing `CreateProcessAsUserW` to use a `:int` instead of `:bool` for the `inherit` flag to fix `shell_out` on windows from a service context 185 | 186 | ## Release 2.2.5 187 | 188 | - [**tschuy**:](https://github.com/tschuy) convert environment hash keys to strings 189 | 190 | ## Release 2.2.3 191 | 192 | - Kill all child processes on Windows when a command times out. 193 | 194 | ## Release 2.2.2 195 | 196 | - Ship gemspec and Gemfiles to facilitate testing. 197 | - Fix #111 by pulling in an updated version of win-32/process and correctly patching Process::create. 198 | - Kill all child processes on Windows when a command times out. 199 | 200 | ## Release 2.2.1 201 | 202 | - Fix executable resolution on Windows when a directory exists with the same name as the command to run 203 | 204 | ## Release 2.2.0 205 | 206 | - Remove windows-pr dependency 207 | 208 | ## Release 2.1.0 209 | 210 | - [**BackSlasher**:](https://github.com/BackSlasher) `login` flag now correctly does the magic on unix to simulate a login shell for a user (secondary groups, environment variables, set primary group and generally emulate `su -`). 211 | - went back to setsid() to drop the controlling tty, fixed old AIX issue with getpgid() via avoiding calling getpgid(). 212 | - converted specs to rspec3 213 | 214 | ## Release: 2.0.1 215 | 216 | - add buffering to the child process status pipe to fix chef-client deadlocks 217 | - fix timeouts on Windows 218 | 219 | ## Release: 2.0.0 220 | 221 | - remove `LC_ALL=C` default setting, consumers should now set this if they still need it. 222 | - Change the minimum required version of Ruby to >= 1.9.3. 223 | 224 | ## Release: 1.6.0 225 | 226 | - [**Steven Proctor**:](https://github.com/stevenproctor) Updated link to posix-spawn in README.md. 227 | - [**Akshay Karle**:](https://github.com/akshaykarle) Added the functionality to reflect $stderr when using live_stream. 228 | - [**Tyler Cipriani**:](https://github.com/thcipriani) Fixed typos in the code. 229 | - [**Max Lincoln**](https://github.com/maxlinc): Support separate live stream for stderr. -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | Please refer to the Chef Community Code of Conduct at https://www.chef.io/code-of-conduct/ 2 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | Please refer to https://github.com/chef/chef/blob/master/CONTRIBUTING.md 2 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source "https://rubygems.org" 2 | 3 | gemspec name: "mixlib-shellout" 4 | 5 | gem "win32-process", "~> 0.9" 6 | # for ruby 3.0, install older ffi before 7 | # installing ffi-requiring stuff 8 | if Gem::Version.new(RUBY_VERSION) < Gem::Version.new("3.1") 9 | gem "ffi", "< 1.17.0" 10 | end 11 | gem "ffi-win32-extensions", "~> 1.0.4" 12 | gem "wmi-lite", "~> 1.0.7" 13 | gem "logger" 14 | 15 | group :test do 16 | gem "cookstyle", ">=7.32.8" 17 | gem "rake" 18 | gem "rspec", "~> 3.0" 19 | end 20 | 21 | group :debug do 22 | gem "pry" 23 | # version lock for old ruby 3.0 - once we're off 24 | # of 3.0, remove this line, let pry-byebug pull 25 | # in whatever it wants 26 | if Gem::Version.new(RUBY_VERSION) < Gem::Version.new("3.1") 27 | gem "byebug", "~> 11.1" 28 | end 29 | gem "pry-byebug" 30 | gem "rb-readline" 31 | end 32 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright [yyyy] [name of copyright owner] 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Mixlib::ShellOut 2 | 3 | [![Build Status](https://badge.buildkite.com/7051b7b35cc19076c35a6e6a9e996807b0c14475ca3f3acd86.svg?branch=main)](https://buildkite.com/chef-oss/chef-mixlib-shellout-master-verify) [![Gem Version](https://badge.fury.io/rb/mixlib-shellout.svg)](https://badge.fury.io/rb/mixlib-shellout) 4 | 5 | **Umbrella Project**: [Chef Foundation](https://github.com/chef/chef-oss-practices/blob/master/projects/chef-foundation.md) 6 | 7 | **Project State**: [Active](https://github.com/chef/chef-oss-practices/blob/master/repo-management/repo-states.md#active) 8 | 9 | **Issues [Response Time Maximum](https://github.com/chef/chef-oss-practices/blob/master/repo-management/repo-states.md)**: 14 days 10 | 11 | **Pull Request [Response Time Maximum](https://github.com/chef/chef-oss-practices/blob/master/repo-management/repo-states.md)**: 14 days 12 | 13 | Provides a simplified interface to shelling out while still collecting both standard out and standard error and providing full control over environment, working directory, uid, gid, etc. 14 | 15 | ## Example 16 | ### Simple Shellout 17 | Invoke find(1) to search for .rb files: 18 | 19 | ```ruby 20 | require 'mixlib/shellout' 21 | find = Mixlib::ShellOut.new("find . -name '*.rb'") 22 | find.run_command 23 | ``` 24 | 25 | If all went well, the results are on `stdout` 26 | 27 | ```ruby 28 | puts find.stdout 29 | ``` 30 | 31 | `find(1)` prints diagnostic info to STDERR: 32 | 33 | ```ruby 34 | puts "error messages" + find.stderr 35 | ``` 36 | 37 | Raise an exception if it didn't exit with 0 38 | 39 | ```ruby 40 | find.error! 41 | ``` 42 | 43 | ### Advanced Shellout 44 | In addition to the command to run there are other options that can be set to change the shellout behavior. The complete list of options can be found here: https://github.com/chef/mixlib-shellout/blob/master/lib/mixlib/shellout.rb 45 | 46 | Run a command as the `www` user with no extra ENV settings from `/tmp` with a 1s timeout 47 | 48 | ```ruby 49 | cmd = Mixlib::ShellOut.new("apachectl", "start", :user => 'www', :environment => nil, :cwd => '/tmp', :timeout => 1) 50 | cmd.run_command # etc. 51 | ``` 52 | 53 | ### STDIN Example 54 | Invoke crontab to edit user cron: 55 | 56 | ```ruby 57 | # :input only supports simple strings 58 | crontab_lines = [ "* * * * * /bin/true", "* * * * * touch /tmp/here" ] 59 | crontab = Mixlib::ShellOut.new("crontab -l -u #{@new_resource.user}", :input => crontab_lines.join("\n")) 60 | crontab.run_command 61 | ``` 62 | 63 | ### Windows Impersonation Example 64 | Invoke "whoami.exe" to demonstrate running a command as another user: 65 | 66 | ```ruby 67 | whoami = Mixlib::ShellOut.new("whoami.exe", :user => "username", :domain => "DOMAIN", :password => "password") 68 | whoami.run_command 69 | ``` 70 | 71 | Invoke "whoami.exe" with elevated privileges: 72 | 73 | ```ruby 74 | whoami = Mixlib::ShellOut.new("whoami.exe", :user => "username", :domain => "DOMAIN", :password => "password", :elevated => true) 75 | whoami.run_command 76 | ``` 77 | 78 | **NOTE:** The user 'admin' must have the 'Log on as a batch job' permission and the user chef is running as must have the 'Replace a process level token' and 'Adjust Memory Quotas for a process' permissions. 79 | 80 | ## Platform Support 81 | Mixlib::ShellOut does a standard fork/exec on Unix, and uses the Win32 API on Windows. There is not currently support for JRuby. 82 | 83 | ## See Also 84 | - `Process.spawn` in Ruby 1.9+ 85 | - [https://github.com/rtomayko/posix-spawn](https://github.com/rtomayko/posix-spawn) 86 | 87 | ## Contributing 88 | 89 | For information on contributing to this project see 90 | 91 | ## License 92 | - Copyright:: Copyright (c) 2011-2016 Chef Software, Inc. 93 | - License:: Apache License, Version 2.0 94 | 95 | ```text 96 | Licensed under the Apache License, Version 2.0 (the "License"); 97 | you may not use this file except in compliance with the License. 98 | You may obtain a copy of the License at 99 | 100 | http://www.apache.org/licenses/LICENSE-2.0 101 | 102 | Unless required by applicable law or agreed to in writing, software 103 | distributed under the License is distributed on an "AS IS" BASIS, 104 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 105 | See the License for the specific language governing permissions and 106 | limitations under the License. 107 | ``` 108 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | require "bundler" 2 | 3 | Bundler::GemHelper.install_tasks name: "mixlib-shellout" 4 | 5 | begin 6 | require "rspec/core/rake_task" 7 | RSpec::Core::RakeTask.new do |t| 8 | t.pattern = "spec/**/*_spec.rb" 9 | end 10 | rescue LoadError 11 | desc "rspec is not installed, this task is disabled" 12 | task :spec do 13 | abort "rspec is not installed. bundle install first to make sure all dependencies are installed." 14 | end 15 | end 16 | 17 | task :console do 18 | require "irb" 19 | require "irb/completion" 20 | require "mixlib/shellout" 21 | ARGV.clear 22 | IRB.start 23 | end 24 | 25 | task default: %i{spec} 26 | -------------------------------------------------------------------------------- /VERSION: -------------------------------------------------------------------------------- 1 | 3.4.3 -------------------------------------------------------------------------------- /lib/mixlib/shellout.rb: -------------------------------------------------------------------------------- 1 | #-- 2 | # Author:: Daniel DeLeo () 3 | # Copyright:: Copyright (c) Chef Software Inc. 4 | # License:: Apache License, Version 2.0 5 | # 6 | # Licensed under the Apache License, Version 2.0 (the "License"); 7 | # you may not use this file except in compliance with the License. 8 | # You may obtain a copy of the License at 9 | # 10 | # http://www.apache.org/licenses/LICENSE-2.0 11 | # 12 | # Unless required by applicable law or agreed to in writing, software 13 | # distributed under the License is distributed on an "AS IS" BASIS, 14 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 15 | # See the License for the specific language governing permissions and 16 | # limitations under the License. 17 | # 18 | 19 | require "etc" unless defined?(Etc) 20 | require "tmpdir" unless defined?(Dir.mktmpdir) 21 | require "fcntl" 22 | require_relative "shellout/exceptions" 23 | 24 | module Mixlib 25 | 26 | class ShellOut 27 | READ_WAIT_TIME = 0.01 28 | READ_SIZE = 4096 29 | DEFAULT_READ_TIMEOUT = 600 30 | 31 | if RUBY_PLATFORM =~ /mswin|mingw|windows/ 32 | require_relative "shellout/windows" 33 | include ShellOut::Windows 34 | else 35 | require_relative "shellout/unix" 36 | include ShellOut::Unix 37 | end 38 | 39 | # User the command will run as. Normally set via options passed to new 40 | attr_accessor :user 41 | attr_accessor :domain 42 | attr_accessor :password 43 | # TODO remove 44 | attr_accessor :with_logon 45 | 46 | # Whether to simulate logon as the user. Normally set via options passed to new 47 | # Always enabled on windows 48 | attr_accessor :login 49 | 50 | # Group the command will run as. Normally set via options passed to new 51 | attr_accessor :group 52 | 53 | # Working directory for the subprocess. Normally set via options to new 54 | attr_accessor :cwd 55 | 56 | # An Array of acceptable exit codes. #error? (and #error!) use this list 57 | # to determine if the command was successful. Normally set via options to new 58 | attr_accessor :valid_exit_codes 59 | 60 | # When live_stdout is set, the stdout of the subprocess will be copied to it 61 | # as the subprocess is running. 62 | attr_accessor :live_stdout 63 | 64 | # When live_stderr is set, the stderr of the subprocess will be copied to it 65 | # as the subprocess is running. 66 | attr_accessor :live_stderr 67 | 68 | # ShellOut will push data from :input down the stdin of the subprocess. 69 | # Normally set via options passed to new. 70 | # Default: nil 71 | attr_accessor :input 72 | 73 | # If a logger is set, ShellOut will log a message before it executes the 74 | # command. 75 | attr_accessor :logger 76 | 77 | # The log level at which ShellOut should log. 78 | attr_accessor :log_level 79 | 80 | # A string which will be prepended to the log message. 81 | attr_accessor :log_tag 82 | 83 | # The command to be executed. 84 | attr_reader :command 85 | 86 | # The umask that will be set for the subcommand. 87 | attr_reader :umask 88 | 89 | # Environment variables that will be set for the subcommand. Refer to the 90 | # documentation of new to understand how ShellOut interprets this. 91 | attr_accessor :environment 92 | 93 | # The maximum time this command is allowed to run. Usually set via options 94 | # to new 95 | attr_writer :timeout 96 | 97 | # The amount of time the subcommand took to execute 98 | attr_reader :execution_time 99 | 100 | # Data written to stdout by the subprocess 101 | attr_reader :stdout 102 | 103 | # Data written to stderr by the subprocess 104 | attr_reader :stderr 105 | 106 | # A Process::Status (or ducktype) object collected when the subprocess is 107 | # reaped. 108 | attr_reader :status 109 | 110 | attr_reader :stdin_pipe, :stdout_pipe, :stderr_pipe, :process_status_pipe 111 | 112 | # Runs windows process with elevated privileges. Required for Powershell commands which need elevated privileges 113 | attr_accessor :elevated 114 | 115 | attr_accessor :sensitive 116 | 117 | # Path to cgroupv2 that the process should run on 118 | attr_accessor :cgroup 119 | 120 | # === Arguments: 121 | # Takes a single command, or a list of command fragments. These are used 122 | # as arguments to Kernel.exec. See the Kernel.exec documentation for more 123 | # explanation of how arguments are evaluated. The last argument can be an 124 | # options Hash. 125 | # === Options: 126 | # If the last argument is a Hash, it is removed from the list of args passed 127 | # to exec and used as an options hash. The following options are available: 128 | # * +user+: the user the command should run as. if an integer is given, it is 129 | # used as a uid. A string is treated as a username and resolved to a uid 130 | # with Etc.getpwnam 131 | # * +group+: the group the command should run as. works similarly to +user+ 132 | # * +cwd+: the directory to chdir to before running the command 133 | # * +umask+: a umask to set before running the command. If given as an Integer, 134 | # be sure to use two leading zeros so it's parsed as Octal. A string will 135 | # be treated as an octal integer 136 | # * +returns+: one or more Integer values to use as valid exit codes for the 137 | # subprocess. This only has an effect if you call +error!+ after 138 | # +run_command+. 139 | # * +environment+: a Hash of environment variables to set before the command 140 | # is run. 141 | # * +timeout+: a Numeric value for the number of seconds to wait on the 142 | # child process before raising an Exception. This is calculated as the 143 | # total amount of time that ShellOut waited on the child process without 144 | # receiving any output (i.e., IO.select returned nil). Default is 600 145 | # seconds. Note: the stdlib Timeout library is not used. 146 | # * +input+: A String of data to be passed to the subcommand. This is 147 | # written to the child process' stdin stream before the process is 148 | # launched. The child's stdin stream will be a pipe, so the size of input 149 | # data should not exceed the system's default pipe capacity (4096 bytes 150 | # is a safe value, though on newer Linux systems the capacity is 64k by 151 | # default). 152 | # * +live_stream+: An IO or Logger-like object (must respond to the append 153 | # operator +<<+) that will receive data as ShellOut reads it from the 154 | # child process. Generally this is used to copy data from the child to 155 | # the parent's stdout so that users may observe the progress of 156 | # long-running commands. 157 | # * +login+: Whether to simulate a login (set secondary groups, primary group, environment 158 | # variables etc) as done by the OS in an actual login 159 | # === Examples: 160 | # Invoke find(1) to search for .rb files: 161 | # find = Mixlib::ShellOut.new("find . -name '*.rb'") 162 | # find.run_command 163 | # # If all went well, the results are on +stdout+ 164 | # puts find.stdout 165 | # # find(1) prints diagnostic info to STDERR: 166 | # puts "error messages" + find.stderr 167 | # # Raise an exception if it didn't exit with 0 168 | # find.error! 169 | # Run a command as the +www+ user with no extra ENV settings from +/tmp+ 170 | # cmd = Mixlib::ShellOut.new("apachectl", "start", :user => 'www', :env => nil, :cwd => '/tmp') 171 | # cmd.run_command # etc. 172 | def initialize(*command_args) 173 | # Since ruby 4.0 will freeze string literals by default, we are assigning mutable strings here. 174 | @stdout, @stderr, @process_status = String.new(""), String.new(""), String.new("") 175 | @live_stdout = @live_stderr = nil 176 | @input = nil 177 | @log_level = :debug 178 | @log_tag = nil 179 | @environment = {} 180 | @cwd = nil 181 | @valid_exit_codes = [0] 182 | @terminate_reason = nil 183 | @timeout = nil 184 | @elevated = false 185 | @sensitive = false 186 | @cgroup = nil 187 | 188 | if command_args.last.is_a?(Hash) 189 | parse_options(command_args.pop) 190 | end 191 | 192 | @command = command_args.size == 1 ? command_args.first : command_args 193 | end 194 | 195 | # Returns the stream that both is being used by both live_stdout and live_stderr, or nil 196 | def live_stream 197 | live_stdout == live_stderr ? live_stdout : nil 198 | end 199 | 200 | # A shortcut for setting both live_stdout and live_stderr, so that both the 201 | # stdout and stderr from the subprocess will be copied to the same stream as 202 | # the subprocess is running. 203 | def live_stream=(stream) 204 | @live_stdout = @live_stderr = stream 205 | end 206 | 207 | # Set the umask that the subprocess will have. If given as a string, it 208 | # will be converted to an integer by String#oct. 209 | def umask=(new_umask) 210 | @umask = (new_umask.respond_to?(:oct) ? new_umask.oct : new_umask.to_i) & 007777 211 | end 212 | 213 | # The uid that the subprocess will switch to. If the user attribute was 214 | # given as a username, it is converted to a uid by Etc.getpwnam 215 | # TODO migrate to shellout/unix.rb 216 | def uid 217 | return nil unless user 218 | 219 | user.is_a?(Integer) ? user : Etc.getpwnam(user.to_s).uid 220 | end 221 | 222 | # The gid that the subprocess will switch to. If the group attribute is 223 | # given as a group name, it is converted to a gid by Etc.getgrnam 224 | # TODO migrate to shellout/unix.rb 225 | def gid 226 | return group.is_a?(Integer) ? group : Etc.getgrnam(group.to_s).gid if group 227 | return Etc.getpwuid(uid).gid if using_login? 228 | 229 | nil 230 | end 231 | 232 | def timeout 233 | @timeout || DEFAULT_READ_TIMEOUT 234 | end 235 | 236 | # Creates a String showing the output of the command, including a banner 237 | # showing the exact command executed. Used by +invalid!+ to show command 238 | # results when the command exited with an unexpected status. 239 | def format_for_exception 240 | return "Command execution failed. STDOUT/STDERR suppressed for sensitive resource" if sensitive 241 | 242 | msg = "" 243 | msg << "#{@terminate_reason}\n" if @terminate_reason 244 | msg << "---- Begin output of #{command} ----\n" 245 | msg << "STDOUT: #{stdout.strip}\n" 246 | msg << "STDERR: #{stderr.strip}\n" 247 | msg << "---- End output of #{command} ----\n" 248 | msg << "Ran #{command} returned #{status.exitstatus}" if status 249 | msg 250 | end 251 | 252 | # The exit status of the subprocess. Will be nil if the command is still 253 | # running or died without setting an exit status (e.g., terminated by 254 | # `kill -9`). 255 | def exitstatus 256 | @status&.exitstatus 257 | end 258 | 259 | # Run the command, writing the command's standard out and standard error 260 | # to +stdout+ and +stderr+, and saving its exit status object to +status+ 261 | # === Returns 262 | # returns +self+; +stdout+, +stderr+, +status+, and +exitstatus+ will be 263 | # populated with results of the command 264 | # === Raises 265 | # * Errno::EACCES when you are not privileged to execute the command 266 | # * Errno::ENOENT when the command is not available on the system (or not 267 | # in the current $PATH) 268 | # * CommandTimeout when the command does not complete 269 | # within +timeout+ seconds (default: 600s) 270 | def run_command 271 | if logger 272 | log_message = (log_tag.nil? ? "" : "#{@log_tag} ") << "sh(#{@command})" 273 | logger.send(log_level, log_message) 274 | end 275 | super 276 | end 277 | 278 | # Checks the +exitstatus+ against the set of +valid_exit_codes+. 279 | # === Returns 280 | # +true+ if +exitstatus+ is not in the list of +valid_exit_codes+, false 281 | # otherwise. 282 | def error? 283 | !Array(valid_exit_codes).include?(exitstatus) 284 | end 285 | 286 | # If #error? is true, calls +invalid!+, which raises an Exception. 287 | # === Returns 288 | # nil::: always returns nil when it does not raise 289 | # === Raises 290 | # ::ShellCommandFailed::: via +invalid!+ 291 | def error! 292 | invalid!("Expected process to exit with #{valid_exit_codes.inspect}, but received '#{exitstatus}'") if error? 293 | end 294 | 295 | # Raises a ShellCommandFailed exception, appending the 296 | # command's stdout, stderr, and exitstatus to the exception message. 297 | # === Arguments 298 | # +msg+: A String to use as the basis of the exception message. The 299 | # default explanation is very generic, providing a more informative message 300 | # is highly encouraged. 301 | # === Raises 302 | # ShellCommandFailed always 303 | def invalid!(msg = nil) 304 | msg ||= "Command produced unexpected results" 305 | raise ShellCommandFailed, msg + "\n" + format_for_exception 306 | end 307 | 308 | def inspect 309 | "<#{self.class.name}##{object_id}: command: '#{@command}' process_status: #{@status.inspect} " + 310 | "stdout: '#{stdout.strip}' stderr: '#{stderr.strip}' child_pid: #{@child_pid.inspect} " + 311 | "environment: #{@environment.inspect} timeout: #{timeout} user: #{@user} group: #{@group} working_dir: #{@cwd} " + 312 | "cgroup: #{@cgroup} >" 313 | end 314 | 315 | private 316 | 317 | def parse_options(opts) 318 | opts.each do |option, setting| 319 | case option.to_s 320 | when "cwd" 321 | self.cwd = setting 322 | when "domain" 323 | self.domain = setting 324 | when "password" 325 | self.password = setting 326 | when "user" 327 | self.user = setting 328 | self.with_logon = setting 329 | when "group" 330 | self.group = setting 331 | when "umask" 332 | self.umask = setting 333 | when "timeout" 334 | self.timeout = setting 335 | when "returns" 336 | self.valid_exit_codes = Array(setting) 337 | when "live_stream" 338 | self.live_stdout = self.live_stderr = setting 339 | when "live_stdout" 340 | self.live_stdout = setting 341 | when "live_stderr" 342 | self.live_stderr = setting 343 | when "input" 344 | self.input = setting 345 | when "logger" 346 | self.logger = setting 347 | when "log_level" 348 | self.log_level = setting 349 | when "log_tag" 350 | self.log_tag = setting 351 | when "environment", "env" 352 | if setting 353 | self.environment = Hash[setting.map { |(k, v)| [k.to_s, v] }] 354 | else 355 | self.environment = {} 356 | end 357 | when "login" 358 | self.login = setting 359 | when "elevated" 360 | self.elevated = setting 361 | when "sensitive" 362 | self.sensitive = setting 363 | when "cgroup" 364 | self.cgroup = setting 365 | else 366 | raise InvalidCommandOption, "option '#{option.inspect}' is not a valid option for #{self.class.name}" 367 | end 368 | end 369 | 370 | validate_options(opts) 371 | end 372 | 373 | def validate_options(opts) 374 | if login && !user 375 | raise InvalidCommandOption, "cannot set login without specifying a user" 376 | end 377 | 378 | super 379 | end 380 | end 381 | end 382 | -------------------------------------------------------------------------------- /lib/mixlib/shellout/exceptions.rb: -------------------------------------------------------------------------------- 1 | module Mixlib 2 | class ShellOut 3 | class Error < RuntimeError; end 4 | class ShellCommandFailed < Error; end 5 | class CommandTimeout < Error; end 6 | class InvalidCommandOption < Error; end 7 | class EmptyWindowsCommand < ShellCommandFailed; end 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /lib/mixlib/shellout/helper.rb: -------------------------------------------------------------------------------- 1 | #-- 2 | # Author:: Daniel DeLeo () 3 | # Copyright:: Copyright (c) Chef Software Inc. 4 | # License:: Apache License, Version 2.0 5 | # 6 | # Licensed under the Apache License, Version 2.0 (the "License"); 7 | # you may not use this file except in compliance with the License. 8 | # You may obtain a copy of the License at 9 | # 10 | # http://www.apache.org/licenses/LICENSE-2.0 11 | # 12 | # Unless required by applicable law or agreed to in writing, software 13 | # distributed under the License is distributed on an "AS IS" BASIS, 14 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 15 | # See the License for the specific language governing permissions and 16 | # limitations under the License. 17 | 18 | require_relative "../shellout" 19 | require "chef-utils" unless defined?(ChefUtils) 20 | require "chef-utils/dsl/default_paths" 21 | require "chef-utils/internal" 22 | 23 | module Mixlib 24 | class ShellOut 25 | module Helper 26 | include ChefUtils::Internal 27 | include ChefUtils::DSL::DefaultPaths 28 | 29 | # 30 | # These APIs are considered public for use in ohai and chef (by cookbooks and plugins, etc) 31 | # but are considered private/experimental for now for the direct users of mixlib-shellout. 32 | # 33 | # You can see an example of how to handle the "dependency injection" in the rspec unit test. 34 | # That backend API is left deliberately undocumented for now and may not follow SemVer and may 35 | # break at any time (at least for the rest of 2020). 36 | # 37 | 38 | def shell_out(*args, **options) 39 | options = options.dup 40 | options = __maybe_add_timeout(self, options) 41 | if options.empty? 42 | shell_out_compacted(*__clean_array(*args)) 43 | else 44 | shell_out_compacted(*__clean_array(*args), **options) 45 | end 46 | end 47 | 48 | def shell_out!(*args, **options) 49 | options = options.dup 50 | options = __maybe_add_timeout(self, options) 51 | if options.empty? 52 | shell_out_compacted!(*__clean_array(*args)) 53 | else 54 | shell_out_compacted!(*__clean_array(*args), **options) 55 | end 56 | end 57 | 58 | private 59 | 60 | # helper sugar for resources that support passing timeouts to shell_out 61 | # 62 | # module method to not pollute namespaces, but that means we need self injected as an arg 63 | # @api private 64 | def __maybe_add_timeout(obj, options) 65 | options = options.dup 66 | # historically resources have not properly declared defaults on their timeouts, so a default default of 900s was enforced here 67 | default_val = 900 68 | return options if options.key?(:timeout) 69 | 70 | # FIXME: need to nuke descendent tracker out of Chef::Provider so we can just define that class here without requiring the 71 | # world, and then just use symbol lookup 72 | if obj.class.ancestors.map(&:name).include?("Chef::Provider") && obj.respond_to?(:new_resource) && obj.new_resource.respond_to?(:timeout) && !options.key?(:timeout) 73 | options[:timeout] = obj.new_resource.timeout ? obj.new_resource.timeout.to_f : default_val 74 | end 75 | options 76 | end 77 | 78 | # helper function to mangle options when `default_env` is true 79 | # 80 | # @api private 81 | def __apply_default_env(options) 82 | options = options.dup 83 | default_env = options.delete(:default_env) 84 | default_env = true if default_env.nil? 85 | if default_env 86 | env_key = options.key?(:env) ? :env : :environment 87 | options[env_key] = { 88 | "LC_ALL" => __config[:internal_locale], 89 | "LANGUAGE" => __config[:internal_locale], 90 | "LANG" => __config[:internal_locale], 91 | __env_path_name => default_paths, 92 | }.update(options[env_key] || {}) 93 | end 94 | options 95 | end 96 | 97 | # The shell_out_compacted/shell_out_compacted! APIs are private but are intended for use 98 | # in rspec tests. They should always be used in rspec tests instead of shell_out to allow 99 | # for less brittle rspec tests. 100 | # 101 | # This expectation: 102 | # 103 | # allow(provider).to receive(:shell_out_compacted!).with("foo", "bar", "baz") 104 | # 105 | # Is met by many different possible calling conventions that mean the same thing: 106 | # 107 | # provider.shell_out!("foo", [ "bar", nil, "baz"]) 108 | # provider.shell_out!(["foo", nil, "bar" ], ["baz"]) 109 | # 110 | # Note that when setting `default_env: false` that you should just setup an expectation on 111 | # :shell_out_compacted for `default_env: false`, rather than the expanded env settings so 112 | # that the default_env implementation can change without breaking unit tests. 113 | # 114 | def shell_out_compacted(*args, **options) 115 | options = __apply_default_env(options) 116 | if options.empty? 117 | __shell_out_command(*args) 118 | else 119 | __shell_out_command(*args, **options) 120 | end 121 | end 122 | 123 | def shell_out_compacted!(*args, **options) 124 | options = __apply_default_env(options) 125 | cmd = if options.empty? 126 | __shell_out_command(*args) 127 | else 128 | __shell_out_command(*args, **options) 129 | end 130 | cmd.error! 131 | cmd 132 | end 133 | 134 | # Helper for subclasses to reject nil out of an array. It allows using the array form of 135 | # shell_out (which avoids the need to surround arguments with quote marks to deal with shells). 136 | # 137 | # @param args [String] variable number of string arguments 138 | # @return [Array] array of strings with nil and null string rejection 139 | # 140 | def __clean_array(*args) 141 | args.flatten.compact.map(&:to_s) 142 | end 143 | 144 | # Join arguments into a string. 145 | # 146 | # Strips leading/trailing spaces from each argument. If an argument contains 147 | # a space, it is quoted. Join into a single string with spaces between each argument. 148 | # 149 | # @param args [String] variable number of string arguments 150 | # @return [String] merged string 151 | # 152 | def __join_whitespace(*args, quote: false) 153 | args.map do |arg| 154 | if arg.is_a?(Array) 155 | __join_whitespace(*arg, quote: arg.count > 1) 156 | else 157 | arg = arg.include?(" ") ? sprintf('"%s"', arg) : arg if quote 158 | arg.strip 159 | end 160 | end.join(" ") 161 | end 162 | 163 | def __shell_out_command(*args, **options) 164 | if __transport_connection 165 | command = __join_whitespace(args) 166 | unless ChefUtils.windows? 167 | if options[:cwd] 168 | # as `timeout` is used, commands need to be executed in a subshell 169 | command = "sh -c 'cd #{options[:cwd]}; #{command}'" 170 | end 171 | 172 | if options[:input] 173 | command.concat "<<'COMMANDINPUT'\n" 174 | command.concat __join_whitespace(options[:input]) 175 | command.concat "\nCOMMANDINPUT\n" 176 | end 177 | end 178 | 179 | # FIXME: train should accept run_command(*args) 180 | FakeShellOut.new(args, options, __transport_connection.run_command(command, options)) 181 | else 182 | cmd = if options.empty? 183 | Mixlib::ShellOut.new(*args) 184 | else 185 | Mixlib::ShellOut.new(*args, **options) 186 | end 187 | cmd.live_stream ||= __io_for_live_stream 188 | cmd.run_command 189 | cmd 190 | end 191 | end 192 | 193 | def __io_for_live_stream 194 | if !STDOUT.closed? && __log.trace? 195 | STDOUT 196 | else 197 | nil 198 | end 199 | end 200 | 201 | def __env_path_name 202 | if ChefUtils.windows? 203 | "Path" 204 | else 205 | "PATH" 206 | end 207 | end 208 | 209 | class FakeShellOut 210 | attr_reader :stdout, :stderr, :exitstatus, :status 211 | 212 | def initialize(args, options, result) 213 | @args = args 214 | @options = options 215 | @stdout = result.stdout 216 | @stderr = result.stderr 217 | @exitstatus = result.exit_status 218 | @valid_exit_codes = Array(options[:returns] || 0) 219 | @status = OpenStruct.new(success?: (@valid_exit_codes.include? exitstatus)) 220 | end 221 | 222 | def error? 223 | @valid_exit_codes.none?(exitstatus) 224 | end 225 | 226 | def error! 227 | raise Mixlib::ShellOut::ShellCommandFailed, "Unexpected exit status of #{exitstatus} running #{@args}: #{stderr}" if error? 228 | end 229 | end 230 | end 231 | end 232 | end 233 | -------------------------------------------------------------------------------- /lib/mixlib/shellout/unix.rb: -------------------------------------------------------------------------------- 1 | # 2 | # Author:: Daniel DeLeo () 3 | # Copyright:: Copyright (c) Chef Software Inc. 4 | # License:: Apache License, Version 2.0 5 | # 6 | # Licensed under the Apache License, Version 2.0 (the "License"); 7 | # you may not use this file except in compliance with the License. 8 | # You may obtain a copy of the License at 9 | # 10 | # http://www.apache.org/licenses/LICENSE-2.0 11 | # 12 | # Unless required by applicable law or agreed to in writing, software 13 | # distributed under the License is distributed on an "AS IS" BASIS, 14 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 15 | # See the License for the specific language governing permissions and 16 | # limitations under the License. 17 | # 18 | 19 | require "fileutils" unless defined?(FileUtils) 20 | 21 | module Mixlib 22 | class ShellOut 23 | module Unix 24 | 25 | # Option validation that is unix specific 26 | def validate_options(opts) 27 | if opts[:elevated] 28 | raise InvalidCommandOption, "Option `elevated` is supported for Powershell commands only" 29 | end 30 | end 31 | 32 | # Whether we're simulating a login shell 33 | def using_login? 34 | login && user 35 | end 36 | 37 | # Helper method for sgids 38 | def all_seconderies 39 | ret = [] 40 | Etc.endgrent 41 | while ( g = Etc.getgrent ) 42 | ret << g 43 | end 44 | Etc.endgrent 45 | ret 46 | end 47 | 48 | # The secondary groups that the subprocess will switch to. 49 | # Currently valid only if login is used, and is set 50 | # to the user's secondary groups 51 | def sgids 52 | return nil unless using_login? 53 | 54 | user_name = Etc.getpwuid(uid).name 55 | all_seconderies.select { |g| g.mem.include?(user_name) }.map(&:gid) 56 | end 57 | 58 | # The environment variables that are deduced from simulating logon 59 | # Only valid if login is used 60 | def logon_environment 61 | return {} unless using_login? 62 | 63 | entry = Etc.getpwuid(uid) 64 | # According to `man su`, the set fields are: 65 | # $HOME, $SHELL, $USER, $LOGNAME, $PATH, and $IFS 66 | # Values are copied from "shadow" package in Ubuntu 14.10 67 | { "HOME" => entry.dir, "SHELL" => entry.shell, "USER" => entry.name, "LOGNAME" => entry.name, "PATH" => "/sbin:/bin:/usr/sbin:/usr/bin", "IFS" => "\t\n" } 68 | end 69 | 70 | # Merges the two environments for the process 71 | def process_environment 72 | logon_environment.merge(environment) 73 | end 74 | 75 | # Run the command, writing the command's standard out and standard error 76 | # to +stdout+ and +stderr+, and saving its exit status object to +status+ 77 | # === Returns 78 | # returns +self+; +stdout+, +stderr+, +status+, and +exitstatus+ will be 79 | # populated with results of the command. 80 | # === Raises 81 | # * Errno::EACCES when you are not privileged to execute the command 82 | # * Errno::ENOENT when the command is not available on the system (or not 83 | # in the current $PATH) 84 | # * Chef::Exceptions::CommandTimeout when the command does not complete 85 | # within +timeout+ seconds (default: 600s). When this happens, ShellOut 86 | # will send a TERM and then KILL to the entire process group to ensure 87 | # that any grandchild processes are terminated. If the invocation of 88 | # the child process spawned multiple child processes (which commonly 89 | # happens if the command is passed as a single string to be interpreted 90 | # by bin/sh, and bin/sh is not bash), the exit status object may not 91 | # contain the correct exit code of the process (of course there is no 92 | # exit code if the command is killed by SIGKILL, also). 93 | def run_command 94 | @child_pid = fork_subprocess 95 | @reaped = false 96 | 97 | configure_parent_process_file_descriptors 98 | 99 | # CHEF-3390: Marshall.load on Ruby < 1.8.7p369 also has a GC bug related 100 | # to Marshall.load, so try disabling GC first. 101 | propagate_pre_exec_failure 102 | 103 | @status = nil 104 | @result = nil 105 | @execution_time = 0 106 | 107 | write_to_child_stdin 108 | 109 | until @status 110 | ready_buffers = attempt_buffer_read 111 | unless ready_buffers 112 | @execution_time += READ_WAIT_TIME 113 | if @execution_time >= timeout && !@result 114 | # kill the bad proccess 115 | reap_errant_child 116 | # read anything it wrote when we killed it 117 | attempt_buffer_read 118 | # raise 119 | raise CommandTimeout, "Command timed out after #{@execution_time.to_i}s:\n#{format_for_exception}" 120 | end 121 | end 122 | 123 | attempt_reap 124 | end 125 | 126 | self 127 | rescue Errno::ENOENT 128 | # When ENOENT happens, we can be reasonably sure that the child process 129 | # is going to exit quickly, so we use the blocking variant of waitpid2 130 | reap 131 | raise 132 | ensure 133 | reap_errant_child if should_reap? 134 | # make one more pass to get the last of the output after the 135 | # child process dies 136 | attempt_buffer_read(0) 137 | # no matter what happens, turn the GC back on, and hope whatever busted 138 | # version of ruby we're on doesn't allocate some objects during the next 139 | # GC run. 140 | GC.enable 141 | close_all_pipes 142 | end 143 | 144 | private 145 | 146 | def set_user 147 | if user 148 | Process.uid = uid 149 | Process.euid = uid 150 | end 151 | end 152 | 153 | def set_group 154 | if group 155 | Process.egid = gid 156 | Process.gid = gid 157 | end 158 | end 159 | 160 | def set_secondarygroups 161 | if sgids 162 | Process.groups = sgids 163 | end 164 | end 165 | 166 | def set_environment 167 | # user-set variables should override the login ones 168 | process_environment.each do |env_var, value| 169 | ENV[env_var] = value 170 | end 171 | end 172 | 173 | def set_umask 174 | File.umask(umask) if umask 175 | end 176 | 177 | def set_cwd 178 | Dir.chdir(cwd) if cwd 179 | end 180 | 181 | def set_cgroup 182 | new_cgroup_path = "/sys/fs/cgroup/#{cgroup}" 183 | # Create cgroup if missing 184 | unless Dir.exist?(new_cgroup_path) 185 | FileUtils.mkdir_p new_cgroup_path 186 | end 187 | # Migrate current process to newly cgroup, any subprocesses will run inside new cgroup 188 | File.write("#{new_cgroup_path}/cgroup.procs", Process.pid.to_s) 189 | end 190 | 191 | # Since we call setsid the child_pgid will be the child_pid, set to negative here 192 | # so it can be directly used in arguments to kill, wait, etc. 193 | def child_pgid 194 | -@child_pid 195 | end 196 | 197 | def initialize_ipc 198 | @stdin_pipe, @stdout_pipe, @stderr_pipe, @process_status_pipe = IO.pipe, IO.pipe, IO.pipe, IO.pipe 199 | @process_status_pipe.last.fcntl(Fcntl::F_SETFD, Fcntl::FD_CLOEXEC) 200 | end 201 | 202 | def child_stdin 203 | @stdin_pipe[1] 204 | end 205 | 206 | def child_stdout 207 | @stdout_pipe[0] 208 | end 209 | 210 | def child_stderr 211 | @stderr_pipe[0] 212 | end 213 | 214 | def child_process_status 215 | @process_status_pipe[0] 216 | end 217 | 218 | def close_all_pipes 219 | child_stdin.close unless child_stdin.closed? 220 | child_stdout.close unless child_stdout.closed? 221 | child_stderr.close unless child_stderr.closed? 222 | child_process_status.close unless child_process_status.closed? 223 | end 224 | 225 | # Replace stdout, and stderr with pipes to the parent, and close the 226 | # reader side of the error marshaling side channel. 227 | # 228 | # If there is no input, close STDIN so when we exec, 229 | # the new program will know it's never getting input ever. 230 | def configure_subprocess_file_descriptors 231 | process_status_pipe.first.close 232 | 233 | # HACK: for some reason, just STDIN.close isn't good enough when running 234 | # under ruby 1.9.2, so make it good enough: 235 | stdin_pipe.last.close 236 | STDIN.reopen stdin_pipe.first 237 | stdin_pipe.first.close unless input 238 | 239 | stdout_pipe.first.close 240 | STDOUT.reopen stdout_pipe.last 241 | stdout_pipe.last.close 242 | 243 | stderr_pipe.first.close 244 | STDERR.reopen stderr_pipe.last 245 | stderr_pipe.last.close 246 | 247 | STDOUT.sync = STDERR.sync = true 248 | STDIN.sync = true if input 249 | end 250 | 251 | def configure_parent_process_file_descriptors 252 | # Close the sides of the pipes we don't care about 253 | stdin_pipe.first.close 254 | stdin_pipe.last.close unless input 255 | stdout_pipe.last.close 256 | stderr_pipe.last.close 257 | process_status_pipe.last.close 258 | # Get output as it happens rather than buffered 259 | child_stdin.sync = true if input 260 | child_stdout.sync = true 261 | child_stderr.sync = true 262 | 263 | true 264 | end 265 | 266 | # Some patch levels of ruby in wide use (in particular the ruby 1.8.6 on OSX) 267 | # segfault when you IO.select a pipe that's reached eof. Weak sauce. 268 | def open_pipes 269 | @open_pipes ||= [child_stdout, child_stderr, child_process_status] 270 | end 271 | 272 | # Keep this unbuffered for now 273 | def write_to_child_stdin 274 | return unless input 275 | 276 | child_stdin << input 277 | child_stdin.close # Kick things off 278 | end 279 | 280 | def attempt_buffer_read(timeout = READ_WAIT_TIME) 281 | ready = IO.select(open_pipes, nil, nil, timeout) 282 | if ready 283 | read_stdout_to_buffer if ready.first.include?(child_stdout) 284 | read_stderr_to_buffer if ready.first.include?(child_stderr) 285 | read_process_status_to_buffer if ready.first.include?(child_process_status) 286 | end 287 | ready 288 | end 289 | 290 | def read_stdout_to_buffer 291 | while ( chunk = child_stdout.read_nonblock(READ_SIZE) ) 292 | @stdout << chunk 293 | @live_stdout << chunk if @live_stdout 294 | end 295 | rescue Errno::EAGAIN 296 | rescue EOFError 297 | open_pipes.delete(child_stdout) 298 | end 299 | 300 | def read_stderr_to_buffer 301 | while ( chunk = child_stderr.read_nonblock(READ_SIZE) ) 302 | @stderr << chunk 303 | @live_stderr << chunk if @live_stderr 304 | end 305 | rescue Errno::EAGAIN 306 | rescue EOFError 307 | open_pipes.delete(child_stderr) 308 | end 309 | 310 | def read_process_status_to_buffer 311 | while ( chunk = child_process_status.read_nonblock(READ_SIZE) ) 312 | @process_status << chunk 313 | end 314 | rescue Errno::EAGAIN 315 | rescue EOFError 316 | open_pipes.delete(child_process_status) 317 | end 318 | 319 | def cgroupv2_available? 320 | File.read("/proc/mounts").match?(%r{^cgroup2 /sys/fs/cgroup}) 321 | end 322 | 323 | def fork_subprocess 324 | initialize_ipc 325 | 326 | fork do 327 | # Child processes may themselves fork off children. A common case 328 | # is when the command is given as a single string (instead of 329 | # command name plus Array of arguments) and /bin/sh does not 330 | # support the "ONESHOT" optimization (where sh -c does exec without 331 | # forking). To support cleaning up all the children, we need to 332 | # ensure they're in a unique process group. 333 | # 334 | # We use setsid here to abandon our controlling tty and get a new session 335 | # and process group that are set to the pid of the child process. 336 | Process.setsid 337 | 338 | configure_subprocess_file_descriptors 339 | 340 | if cgroup && cgroupv2_available? 341 | set_cgroup 342 | end 343 | 344 | set_secondarygroups 345 | set_group 346 | set_user 347 | set_environment 348 | set_umask 349 | set_cwd 350 | 351 | begin 352 | command.is_a?(Array) ? exec(*command, close_others: true) : exec(command, close_others: true) 353 | 354 | raise "forty-two" # Should never get here 355 | rescue Exception => e 356 | Marshal.dump(e, process_status_pipe.last) 357 | process_status_pipe.last.flush 358 | end 359 | process_status_pipe.last.close unless process_status_pipe.last.closed? 360 | exit! 361 | end 362 | end 363 | 364 | # Attempt to get a Marshaled error from the side-channel. 365 | # If it's there, un-marshal it and raise. If it's not there, 366 | # assume everything went well. 367 | def propagate_pre_exec_failure 368 | attempt_buffer_read until child_process_status.eof? 369 | e = Marshal.load(@process_status) 370 | raise(Exception === e ? e : "unknown failure: #{e.inspect}") 371 | rescue ArgumentError # If we get an ArgumentError error, then the exec was successful 372 | true 373 | ensure 374 | child_process_status.close 375 | open_pipes.delete(child_process_status) 376 | end 377 | 378 | def reap_errant_child 379 | return if attempt_reap 380 | 381 | @terminate_reason = "Command exceeded allowed execution time, process terminated" 382 | logger&.error("Command exceeded allowed execution time, sending TERM") 383 | Process.kill(:TERM, child_pgid) 384 | sleep 3 385 | attempt_reap 386 | logger&.error("Command exceeded allowed execution time, sending KILL") 387 | Process.kill(:KILL, child_pgid) 388 | reap 389 | 390 | # Should not hit this but it's possible if something is calling waitall 391 | # in a separate thread. 392 | rescue Errno::ESRCH 393 | nil 394 | end 395 | 396 | def should_reap? 397 | # if we fail to fork, no child pid so nothing to reap 398 | @child_pid && !@reaped 399 | end 400 | 401 | # Unconditionally reap the child process. This is used in scenarios where 402 | # we can be confident the child will exit quickly, and has not spawned 403 | # and grandchild processes. 404 | def reap 405 | results = Process.waitpid2(@child_pid) 406 | @reaped = true 407 | @status = results.last 408 | rescue Errno::ECHILD 409 | # When cleaning up timed-out processes, we might send SIGKILL to the 410 | # whole process group after we've cleaned up the direct child. In that 411 | # case the grandchildren will have been adopted by init so we can't 412 | # reap them even if we wanted to (we don't). 413 | nil 414 | end 415 | 416 | # Try to reap the child process but don't block if it isn't dead yet. 417 | def attempt_reap 418 | results = Process.waitpid2(@child_pid, Process::WNOHANG) 419 | if results 420 | @reaped = true 421 | @status = results.last 422 | else 423 | nil 424 | end 425 | end 426 | 427 | end 428 | end 429 | end 430 | -------------------------------------------------------------------------------- /lib/mixlib/shellout/version.rb: -------------------------------------------------------------------------------- 1 | module Mixlib 2 | class ShellOut 3 | VERSION = "3.4.3".freeze 4 | end 5 | end 6 | -------------------------------------------------------------------------------- /lib/mixlib/shellout/windows.rb: -------------------------------------------------------------------------------- 1 | # 2 | # Author:: Daniel DeLeo () 3 | # Author:: John Keiser () 4 | # Author:: Ho-Sheng Hsiao () 5 | # Copyright:: Copyright (c) Chef Software Inc. 6 | # License:: Apache License, Version 2.0 7 | # 8 | # Licensed under the Apache License, Version 2.0 (the "License"); 9 | # you may not use this file except in compliance with the License. 10 | # You may obtain a copy of the License at 11 | # 12 | # http://www.apache.org/licenses/LICENSE-2.0 13 | # 14 | # Unless required by applicable law or agreed to in writing, software 15 | # distributed under the License is distributed on an "AS IS" BASIS, 16 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 17 | # See the License for the specific language governing permissions and 18 | # limitations under the License. 19 | # 20 | 21 | require "win32/process" 22 | require_relative "windows/core_ext" 23 | 24 | module Mixlib 25 | class ShellOut 26 | module Windows 27 | 28 | include Process::Functions 29 | include Process::Constants 30 | 31 | TIME_SLICE = 0.05 32 | 33 | # Option validation that is windows specific 34 | def validate_options(opts) 35 | if opts[:user] && !opts[:password] 36 | raise InvalidCommandOption, "You must supply a password when supplying a user in windows" 37 | end 38 | 39 | if !opts[:user] && opts[:password] 40 | raise InvalidCommandOption, "You must supply a user when supplying a password in windows" 41 | end 42 | 43 | if opts[:elevated] && !opts[:user] && !opts[:password] 44 | raise InvalidCommandOption, "`elevated` option should be passed only with `username` and `password`." 45 | end 46 | 47 | if opts[:elevated] && opts[:elevated] != true && opts[:elevated] != false 48 | raise InvalidCommandOption, "Invalid value passed for `elevated`. Please provide true/false." 49 | end 50 | end 51 | 52 | #-- 53 | # Missing lots of features from the UNIX version, such as 54 | # uid, etc. 55 | def run_command 56 | # 57 | # Create pipes to capture stdout and stderr, 58 | # 59 | stdout_read, stdout_write = IO.pipe 60 | stderr_read, stderr_write = IO.pipe 61 | stdin_read, stdin_write = IO.pipe 62 | open_streams = [ stdout_read, stderr_read ] 63 | @execution_time = 0 64 | 65 | begin 66 | 67 | # 68 | # Set cwd, environment, appname, etc. 69 | # 70 | app_name, command_line = command_to_run(combine_args(*command)) 71 | create_process_args = { 72 | app_name:, 73 | command_line:, 74 | startup_info: { 75 | stdout: stdout_write, 76 | stderr: stderr_write, 77 | stdin: stdin_read, 78 | }, 79 | environment: inherit_environment.map { |k, v| "#{k}=#{v}" }, 80 | close_handles: false, 81 | } 82 | create_process_args[:cwd] = cwd if cwd 83 | # default to local account database if domain is not specified 84 | create_process_args[:domain] = domain.nil? ? "." : domain 85 | create_process_args[:with_logon] = with_logon if with_logon 86 | create_process_args[:password] = password if password 87 | create_process_args[:elevated] = elevated if elevated 88 | 89 | # 90 | # Start the process 91 | # 92 | process, profile, token = Process.create3(create_process_args) 93 | logger&.debug(format_process(process, app_name, command_line, timeout)) 94 | begin 95 | # Start pushing data into input 96 | stdin_write << input if input 97 | 98 | # Close pipe to kick things off 99 | stdin_write.close 100 | 101 | # 102 | # Wait for the process to finish, consuming output as we go 103 | # 104 | start_wait = Time.now 105 | loop do 106 | wait_status = WaitForSingleObject(process.process_handle, 0) 107 | case wait_status 108 | when WAIT_OBJECT_0 109 | # Save the execution time 110 | @execution_time = Time.now - start_wait 111 | # Get process exit code 112 | exit_code = [0].pack("l") 113 | unless GetExitCodeProcess(process.process_handle, exit_code) 114 | raise get_last_error 115 | end 116 | 117 | @status = ThingThatLooksSortOfLikeAProcessStatus.new 118 | @status.exitstatus = exit_code.unpack("l").first 119 | 120 | return self 121 | when WAIT_TIMEOUT 122 | # Kill the process 123 | if (Time.now - start_wait) > timeout 124 | begin 125 | require "wmi-lite/wmi" 126 | wmi = WmiLite::Wmi.new 127 | kill_process_tree(process.process_id, wmi, logger) 128 | Process.kill(:KILL, process.process_id) 129 | rescue SystemCallError 130 | logger&.warn("Failed to kill timed out process #{process.process_id}") 131 | end 132 | 133 | # Save the execution time 134 | @execution_time = Time.now - start_wait 135 | 136 | raise Mixlib::ShellOut::CommandTimeout, [ 137 | "command timed out:", 138 | format_for_exception, 139 | format_process(process, app_name, command_line, timeout), 140 | ].join("\n") 141 | end 142 | 143 | consume_output(open_streams, stdout_read, stderr_read) 144 | else 145 | raise "Unknown response from WaitForSingleObject(#{process.process_handle}, #{timeout * 1000}): #{wait_status}" 146 | end 147 | 148 | end 149 | 150 | ensure 151 | CloseHandle(process.thread_handle) if process.thread_handle 152 | CloseHandle(process.process_handle) if process.process_handle 153 | Process.unload_user_profile(token, profile) if profile 154 | CloseHandle(token) if token 155 | end 156 | 157 | ensure 158 | # 159 | # Consume all remaining data from the pipes until they are closed 160 | # 161 | stdout_write.close 162 | stderr_write.close 163 | 164 | while consume_output(open_streams, stdout_read, stderr_read) 165 | end 166 | end 167 | end 168 | 169 | class ThingThatLooksSortOfLikeAProcessStatus 170 | attr_accessor :exitstatus 171 | def success? 172 | exitstatus == 0 173 | end 174 | end 175 | 176 | private 177 | 178 | def consume_output(open_streams, stdout_read, stderr_read) 179 | return false if open_streams.length == 0 180 | 181 | ready = IO.select(open_streams, nil, nil, READ_WAIT_TIME) 182 | return true unless ready 183 | 184 | if ready.first.include?(stdout_read) 185 | begin 186 | next_chunk = stdout_read.readpartial(READ_SIZE) 187 | @stdout << next_chunk 188 | @live_stdout << next_chunk if @live_stdout 189 | rescue EOFError 190 | stdout_read.close 191 | open_streams.delete(stdout_read) 192 | end 193 | end 194 | 195 | if ready.first.include?(stderr_read) 196 | begin 197 | next_chunk = stderr_read.readpartial(READ_SIZE) 198 | @stderr << next_chunk 199 | @live_stderr << next_chunk if @live_stderr 200 | rescue EOFError 201 | stderr_read.close 202 | open_streams.delete(stderr_read) 203 | end 204 | end 205 | 206 | true 207 | end 208 | 209 | # Use to support array passing semantics on windows 210 | # 211 | # 1. strings with whitespace or quotes in them need quotes around them. 212 | # 2. interior quotes need to get backslash escaped (parser needs to know when it really ends). 213 | # 3. random backlsashes in paths themselves remain untouched. 214 | # 4. if the argument must be quoted by #1 and terminates in a sequence of backslashes then all the backlashes must themselves 215 | # be backslash excaped (double the backslashes). 216 | # 5. if an interior quote that must be escaped by #2 has a sequence of backslashes before it then all the backslashes must 217 | # themselves be backslash excaped along with the backslash escape of the interior quote (double plus one backslashes). 218 | # 219 | # And to restate. We are constructing a string which will be parsed by the windows parser into arguments, and we want those 220 | # arguments to match the *args array we are passed here. So call the windows parser operation A then we need to apply A^-1 to 221 | # our args to construct the string so that applying A gives windows back our *args. 222 | # 223 | # And when the windows parser sees a series of backslashes followed by a double quote, it has to determine if that double quote 224 | # is terminating or not, and how many backslashes to insert in the args. So what it does is divide it by two (rounding down) to 225 | # get the number of backslashes to insert. Then if it is even the double quotes terminate the argument. If it is even the 226 | # double quotes are interior double quotes (the extra backslash quotes the double quote). 227 | # 228 | # We construct the inverse operation so interior double quotes preceeded by N backslashes get 2N+1 backslashes in front of the quote, 229 | # while trailing N backslashes get 2N backslashes in front of the quote that terminates the argument. 230 | # 231 | # see: https://blogs.msdn.microsoft.com/twistylittlepassagesallalike/2011/04/23/everyone-quotes-command-line-arguments-the-wrong-way/ 232 | # 233 | # @api private 234 | # @param args [Array] array of command arguments 235 | # @return String 236 | def combine_args(*args) 237 | return args[0] if args.length == 1 238 | 239 | args.map do |arg| 240 | if arg =~ /[ \t\n\v"]/ 241 | arg = arg.gsub(/(\\*)"/, '\1\1\"') # interior quotes with N preceeding backslashes need 2N+1 backslashes 242 | arg = arg.sub(/(\\+)$/, '\1\1') # trailing N backslashes need to become 2N backslashes 243 | "\"#{arg}\"" 244 | else 245 | arg 246 | end 247 | end.join(" ") 248 | end 249 | 250 | def command_to_run(command) 251 | return run_under_cmd(command) if should_run_under_cmd?(command) 252 | 253 | candidate = candidate_executable_for_command(command) 254 | 255 | if candidate.length == 0 256 | raise Mixlib::ShellOut::EmptyWindowsCommand, "could not parse script/executable out of command: `#{command}`" 257 | end 258 | 259 | # Check if the exe exists directly. Otherwise, search PATH. 260 | exe = which(candidate) 261 | if exe_needs_cmd?(exe) 262 | run_under_cmd(command) 263 | else 264 | [ exe, command ] 265 | end 266 | end 267 | 268 | # Batch files MUST use cmd; and if we couldn't find the command we're looking for, 269 | # we assume it must be a cmd builtin. 270 | def exe_needs_cmd?(exe) 271 | !exe || exe =~ /\.bat"?$|\.cmd"?$/i 272 | end 273 | 274 | # cmd does not parse multiple quotes well unless the whole thing is wrapped up in quotes. 275 | # https://github.com/chef/mixlib-shellout/pull/2#issuecomment-4837859 276 | # http://ss64.com/nt/syntax-esc.html 277 | def run_under_cmd(command) 278 | [ ENV["COMSPEC"], "cmd /c \"#{command}\"" ] 279 | end 280 | 281 | # FIXME: this extracts ARGV[0] but is it correct? 282 | def candidate_executable_for_command(command) 283 | if command =~ /^\s*"(.*?)"/ || command =~ /^\s*([^\s]+)/ 284 | # If we have quotes, do an exact match, else pick the first word ignoring the leading spaces 285 | $1 286 | else 287 | "" 288 | end 289 | end 290 | 291 | def inherit_environment 292 | result = {} 293 | ENV.each_pair do |k, v| 294 | result[k] = v 295 | end 296 | 297 | environment.each_pair do |k, v| 298 | if v.nil? 299 | result.delete(k) 300 | else 301 | result[k] = v 302 | end 303 | end 304 | result 305 | end 306 | 307 | # api: semi-private 308 | # If there are special characters parsable by cmd.exe (such as file redirection), then 309 | # this method should return true. 310 | # 311 | # This parser is based on 312 | # https://github.com/ruby/ruby/blob/9073db5cb1d3173aff62be5b48d00f0fb2890991/win32/win32.c#L1437 313 | def should_run_under_cmd?(command) 314 | return true if command =~ /^@/ 315 | 316 | quote = nil 317 | env = false 318 | env_first_char = false 319 | 320 | command.dup.each_char do |c| 321 | case c 322 | when "'", '"' 323 | if !quote 324 | quote = c 325 | elsif quote == c 326 | quote = nil 327 | end 328 | next 329 | when ">", "<", "|", "&", "\n" 330 | return true unless quote 331 | when "%" 332 | return true if env 333 | 334 | env = env_first_char = true 335 | next 336 | else 337 | next unless env 338 | 339 | if env_first_char 340 | env_first_char = false 341 | (env = false) && next if c !~ /[A-Za-z_]/ 342 | end 343 | env = false if c !~ /[A-Za-z1-9_]/ 344 | end 345 | end 346 | false 347 | end 348 | 349 | # FIXME: reduce code duplication with chef/chef 350 | def which(cmd) 351 | exts = ENV["PATHEXT"] ? ENV["PATHEXT"].split(";") + [""] : [""] 352 | # windows always searches '.' first 353 | exts.each do |ext| 354 | filename = "#{cmd}#{ext}" 355 | return filename if File.executable?(filename) && !File.directory?(filename) 356 | end 357 | # only search through the path if the Filename does not contain separators 358 | if File.basename(cmd) == cmd 359 | paths = ENV["PATH"].split(File::PATH_SEPARATOR) 360 | paths.each do |path| 361 | exts.each do |ext| 362 | filename = File.join(path, "#{cmd}#{ext}") 363 | return filename if File.executable?(filename) && !File.directory?(filename) 364 | end 365 | end 366 | end 367 | false 368 | end 369 | 370 | def system_required_processes 371 | [ 372 | "System Idle Process", 373 | "System", 374 | "spoolsv.exe", 375 | "lsass.exe", 376 | "csrss.exe", 377 | "smss.exe", 378 | "svchost.exe", 379 | ] 380 | end 381 | 382 | def unsafe_process?(name, logger) 383 | return false unless system_required_processes.include? name 384 | 385 | logger.debug( 386 | "A request to kill a critical system process - #{name} - was received and skipped." 387 | ) 388 | true 389 | end 390 | 391 | # recursively kills all child processes of given pid 392 | # calls itself querying for children child procs until 393 | # none remain. Important that a single WmiLite instance 394 | # is passed in since each creates its own WMI rpc process 395 | def kill_process_tree(pid, wmi, logger) 396 | wmi.query("select * from Win32_Process where ParentProcessID=#{pid}").each do |instance| 397 | next if unsafe_process?(instance.wmi_ole_object.name, logger) 398 | 399 | child_pid = instance.wmi_ole_object.processid 400 | kill_process_tree(child_pid, wmi, logger) 401 | kill_process(instance, logger) 402 | end 403 | end 404 | 405 | def kill_process(instance, logger) 406 | child_pid = instance.wmi_ole_object.processid 407 | logger&.debug([ 408 | "killing child process #{child_pid}::", 409 | "#{instance.wmi_ole_object.Name} of parent #{pid}", 410 | ].join) 411 | Process.kill(:KILL, instance.wmi_ole_object.processid) 412 | rescue SystemCallError 413 | logger&.debug([ 414 | "Failed to kill child process #{child_pid}::", 415 | "#{instance.wmi_ole_object.Name} of parent #{pid}", 416 | ].join) 417 | end 418 | 419 | def format_process(process, app_name, command_line, timeout) 420 | msg = [] 421 | msg << "ProcessId: #{process.process_id}" 422 | msg << "app_name: #{app_name}" 423 | msg << "command_line: #{command_line}" 424 | msg << "timeout: #{timeout}" 425 | msg.join("\n") 426 | end 427 | 428 | # DEPRECATED do not use 429 | class Utils 430 | include Mixlib::ShellOut::Windows 431 | def self.should_run_under_cmd?(cmd) 432 | Mixlib::ShellOut::Windows::Utils.new.send(:should_run_under_cmd?, cmd) 433 | end 434 | end 435 | end 436 | end 437 | end 438 | -------------------------------------------------------------------------------- /lib/mixlib/shellout/windows/core_ext.rb: -------------------------------------------------------------------------------- 1 | # 2 | # Author:: Daniel DeLeo () 3 | # Author:: John Keiser () 4 | # Copyright:: Copyright (c) Chef Software Inc. 5 | # License:: Apache License, Version 2.0 6 | # 7 | # Licensed under the Apache License, Version 2.0 (the "License"); 8 | # you may not use this file except in compliance with the License. 9 | # You may obtain a copy of the License at 10 | # 11 | # http://www.apache.org/licenses/LICENSE-2.0 12 | # 13 | # Unless required by applicable law or agreed to in writing, software 14 | # distributed under the License is distributed on an "AS IS" BASIS, 15 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 16 | # See the License for the specific language governing permissions and 17 | # limitations under the License. 18 | # 19 | 20 | require "win32/process" 21 | require "ffi/win32/extensions" 22 | 23 | # Add new constants for Logon 24 | module Process::Constants 25 | 26 | LOGON32_LOGON_INTERACTIVE = 0x00000002 27 | LOGON32_LOGON_BATCH = 0x00000004 28 | LOGON32_PROVIDER_DEFAULT = 0x00000000 29 | UOI_NAME = 0x00000002 30 | 31 | WAIT_OBJECT_0 = 0 32 | WAIT_TIMEOUT = 0x102 33 | WAIT_ABANDONED = 128 34 | WAIT_ABANDONED_0 = WAIT_ABANDONED 35 | WAIT_FAILED = 0xFFFFFFFF 36 | 37 | ERROR_PRIVILEGE_NOT_HELD = 1314 38 | ERROR_LOGON_TYPE_NOT_GRANTED = 0x569 39 | 40 | # Only documented in Userenv.h ??? 41 | # - ZERO (type Local) is assumed, no docs found 42 | WIN32_PROFILETYPE_LOCAL = 0x00 43 | WIN32_PROFILETYPE_PT_TEMPORARY = 0x01 44 | WIN32_PROFILETYPE_PT_ROAMING = 0x02 45 | WIN32_PROFILETYPE_PT_MANDATORY = 0x04 46 | WIN32_PROFILETYPE_PT_ROAMING_PREEXISTING = 0x08 47 | 48 | # The environment block list ends with two nulls (\0\0). 49 | ENVIRONMENT_BLOCK_ENDS = "\0\0".freeze 50 | end 51 | 52 | # Structs required for data handling 53 | module Process::Structs 54 | 55 | class PROFILEINFO < FFI::Struct 56 | layout( 57 | :dwSize, :dword, 58 | :dwFlags, :dword, 59 | :lpUserName, :pointer, 60 | :lpProfilePath, :pointer, 61 | :lpDefaultPath, :pointer, 62 | :lpServerName, :pointer, 63 | :lpPolicyPath, :pointer, 64 | :hProfile, :handle 65 | ) 66 | end 67 | 68 | end 69 | 70 | # Define the functions needed to check with Service windows station 71 | module Process::Functions 72 | ffi_lib :userenv 73 | 74 | attach_pfunc :GetProfileType, 75 | [:pointer], :bool 76 | 77 | attach_pfunc :LoadUserProfileW, 78 | %i{handle pointer}, :bool 79 | 80 | attach_pfunc :UnloadUserProfile, 81 | %i{handle handle}, :bool 82 | 83 | attach_pfunc :CreateEnvironmentBlock, 84 | %i{pointer ulong bool}, :bool 85 | 86 | attach_pfunc :DestroyEnvironmentBlock, 87 | %i{pointer}, :bool 88 | 89 | ffi_lib :advapi32 90 | 91 | attach_pfunc :LogonUserW, 92 | %i{buffer_in buffer_in buffer_in ulong ulong pointer}, :bool 93 | 94 | attach_pfunc :CreateProcessAsUserW, 95 | %i{ulong buffer_in buffer_inout pointer pointer int 96 | ulong buffer_in buffer_in pointer pointer}, :bool 97 | 98 | ffi_lib :user32 99 | 100 | attach_pfunc :GetProcessWindowStation, 101 | [], :ulong 102 | 103 | attach_pfunc :GetUserObjectInformationA, 104 | %i{ulong uint buffer_out ulong pointer}, :bool 105 | end 106 | 107 | # Override Process.create to check for running in the Service window station and doing 108 | # a full logon with LogonUser, instead of a CreateProcessWithLogon 109 | # Cloned from https://github.com/djberg96/win32-process/blob/ffi/lib/win32/process.rb 110 | # as of 2015-10-15 from commit cc066e5df25048f9806a610f54bf5f7f253e86f7 111 | module Process 112 | 113 | class UnsupportedFeature < StandardError; end 114 | 115 | # Explicitly reopen singleton class so that class/constant declarations from 116 | # extensions are visible in Modules.nesting. 117 | class << self 118 | 119 | def create(args) 120 | create3(args).first 121 | end 122 | 123 | def create3(args) 124 | unless args.is_a?(Hash) 125 | raise TypeError, "hash keyword arguments expected" 126 | end 127 | 128 | valid_keys = %w{ 129 | app_name command_line inherit creation_flags cwd environment 130 | startup_info thread_inherit process_inherit close_handles with_logon 131 | domain password elevated 132 | } 133 | 134 | valid_si_keys = %w{ 135 | startf_flags desktop title x y x_size y_size x_count_chars 136 | y_count_chars fill_attribute sw_flags stdin stdout stderr 137 | } 138 | 139 | # Set default values 140 | hash = { 141 | "app_name" => nil, 142 | "creation_flags" => 0, 143 | "close_handles" => true, 144 | } 145 | 146 | # Validate the keys, and convert symbols and case to lowercase strings. 147 | args.each do |key, val| 148 | key = key.to_s.downcase 149 | unless valid_keys.include?(key) 150 | raise ArgumentError, "invalid key '#{key}'" 151 | end 152 | 153 | hash[key] = val 154 | end 155 | 156 | si_hash = {} 157 | 158 | # If the startup_info key is present, validate its subkeys 159 | hash["startup_info"]&.each do |key, val| 160 | key = key.to_s.downcase 161 | unless valid_si_keys.include?(key) 162 | raise ArgumentError, "invalid startup_info key '#{key}'" 163 | end 164 | 165 | si_hash[key] = val 166 | end 167 | 168 | # The +command_line+ key is mandatory unless the +app_name+ key 169 | # is specified. 170 | unless hash["command_line"] 171 | if hash["app_name"] 172 | hash["command_line"] = hash["app_name"] 173 | hash["app_name"] = nil 174 | else 175 | raise ArgumentError, "command_line or app_name must be specified" 176 | end 177 | end 178 | 179 | env = nil 180 | 181 | # Retrieve the environment variables for the specified user. 182 | if hash["with_logon"] 183 | logon, passwd, domain = format_creds_from_hash(hash) 184 | logon_type = hash["elevated"] ? LOGON32_LOGON_BATCH : LOGON32_LOGON_INTERACTIVE 185 | token = logon_user(logon, domain, passwd, logon_type) 186 | logon_ptr = FFI::MemoryPointer.from_string(logon) 187 | profile = PROFILEINFO.new.tap do |dat| 188 | dat[:dwSize] = dat.size 189 | dat[:dwFlags] = 1 190 | dat[:lpUserName] = logon_ptr 191 | end 192 | 193 | load_user_profile(token, profile.pointer) 194 | env_list = retrieve_environment_variables(token) 195 | end 196 | 197 | # The env string should be passed as a string of ';' separated paths. 198 | if hash["environment"] 199 | env = env_list.nil? ? hash["environment"] : merge_env_variables(env_list, hash["environment"]) 200 | 201 | unless env.respond_to?(:join) 202 | env = hash["environment"].split(File::PATH_SEPARATOR) 203 | end 204 | 205 | env = env.map { |e| e + 0.chr }.join("") + 0.chr 206 | env.to_wide_string! if hash["with_logon"] 207 | end 208 | 209 | # Process SECURITY_ATTRIBUTE structure 210 | process_security = nil 211 | 212 | if hash["process_inherit"] 213 | process_security = SECURITY_ATTRIBUTES.new 214 | process_security[:nLength] = 12 215 | process_security[:bInheritHandle] = 1 216 | end 217 | 218 | # Thread SECURITY_ATTRIBUTE structure 219 | thread_security = nil 220 | 221 | if hash["thread_inherit"] 222 | thread_security = SECURITY_ATTRIBUTES.new 223 | thread_security[:nLength] = 12 224 | thread_security[:bInheritHandle] = 1 225 | end 226 | 227 | # Automatically handle stdin, stdout and stderr as either IO objects 228 | # or file descriptors. This won't work for StringIO, however. It also 229 | # will not work on JRuby because of the way it handles internal file 230 | # descriptors. 231 | # 232 | %w{stdin stdout stderr}.each do |io| 233 | if si_hash[io] 234 | if si_hash[io].respond_to?(:fileno) 235 | handle = get_osfhandle(si_hash[io].fileno) 236 | else 237 | handle = get_osfhandle(si_hash[io]) 238 | end 239 | 240 | if handle == INVALID_HANDLE_VALUE 241 | ptr = FFI::MemoryPointer.new(:int) 242 | 243 | if windows_version >= 6 && get_errno(ptr) == 0 244 | errno = ptr.read_int 245 | else 246 | errno = FFI.errno 247 | end 248 | 249 | raise SystemCallError.new("get_osfhandle", errno) 250 | end 251 | 252 | # Most implementations of Ruby on Windows create inheritable 253 | # handles by default, but some do not. RF bug #26988. 254 | bool = SetHandleInformation( 255 | handle, 256 | HANDLE_FLAG_INHERIT, 257 | HANDLE_FLAG_INHERIT 258 | ) 259 | 260 | raise SystemCallError.new("SetHandleInformation", FFI.errno) unless bool 261 | 262 | si_hash[io] = handle 263 | si_hash["startf_flags"] ||= 0 264 | si_hash["startf_flags"] |= STARTF_USESTDHANDLES 265 | hash["inherit"] = true 266 | end 267 | end 268 | 269 | procinfo = PROCESS_INFORMATION.new 270 | startinfo = STARTUPINFO.new 271 | 272 | unless si_hash.empty? 273 | startinfo[:cb] = startinfo.size 274 | startinfo[:lpDesktop] = si_hash["desktop"] if si_hash["desktop"] 275 | startinfo[:lpTitle] = si_hash["title"] if si_hash["title"] 276 | startinfo[:dwX] = si_hash["x"] if si_hash["x"] 277 | startinfo[:dwY] = si_hash["y"] if si_hash["y"] 278 | startinfo[:dwXSize] = si_hash["x_size"] if si_hash["x_size"] 279 | startinfo[:dwYSize] = si_hash["y_size"] if si_hash["y_size"] 280 | startinfo[:dwXCountChars] = si_hash["x_count_chars"] if si_hash["x_count_chars"] 281 | startinfo[:dwYCountChars] = si_hash["y_count_chars"] if si_hash["y_count_chars"] 282 | startinfo[:dwFillAttribute] = si_hash["fill_attribute"] if si_hash["fill_attribute"] 283 | startinfo[:dwFlags] = si_hash["startf_flags"] if si_hash["startf_flags"] 284 | startinfo[:wShowWindow] = si_hash["sw_flags"] if si_hash["sw_flags"] 285 | startinfo[:cbReserved2] = 0 286 | startinfo[:hStdInput] = si_hash["stdin"] if si_hash["stdin"] 287 | startinfo[:hStdOutput] = si_hash["stdout"] if si_hash["stdout"] 288 | startinfo[:hStdError] = si_hash["stderr"] if si_hash["stderr"] 289 | end 290 | 291 | app = nil 292 | cmd = nil 293 | 294 | # Convert strings to wide character strings if present 295 | if hash["app_name"] 296 | app = hash["app_name"].to_wide_string 297 | end 298 | 299 | if hash["command_line"] 300 | cmd = hash["command_line"].to_wide_string 301 | end 302 | 303 | if hash["cwd"] 304 | cwd = hash["cwd"].to_wide_string 305 | end 306 | 307 | inherit = hash["inherit"] ? 1 : 0 308 | 309 | if hash["with_logon"] 310 | 311 | logon, passwd, domain = format_creds_from_hash(hash) 312 | 313 | hash["creation_flags"] |= CREATE_UNICODE_ENVIRONMENT 314 | 315 | winsta_name = get_windows_station_name 316 | 317 | # If running in the service windows station must do a log on to get 318 | # to the interactive desktop. The running process user account must have 319 | # the 'Replace a process level token' permission. This is necessary as 320 | # the logon (which happens with CreateProcessWithLogon) must have an 321 | # interactive windows station to attach to, which is created with the 322 | # LogonUser call with the LOGON32_LOGON_INTERACTIVE flag. 323 | # 324 | # User Access Control (UAC) only applies to interactive logons, so we 325 | # can simulate running a command 'elevated' by running it under a separate 326 | # logon as a batch process. 327 | if hash["elevated"] || winsta_name =~ /^Service-0x0-.*$/i 328 | 329 | logon_type = hash["elevated"] ? LOGON32_LOGON_BATCH : LOGON32_LOGON_INTERACTIVE 330 | token = logon_user(logon, domain, passwd, logon_type) 331 | logon_ptr = FFI::MemoryPointer.from_string(logon) 332 | profile = PROFILEINFO.new.tap do |dat| 333 | dat[:dwSize] = dat.size 334 | dat[:dwFlags] = 1 335 | dat[:lpUserName] = logon_ptr 336 | end 337 | 338 | if logon_has_roaming_profile? 339 | msg = %w{ 340 | Mixlib does not currently support executing commands as users 341 | configured with Roaming Profiles. [%s] 342 | }.join(" ") % logon.encode("UTF-8").unpack("A*") 343 | raise UnsupportedFeature.new(msg) 344 | end 345 | 346 | load_user_profile(token, profile.pointer) 347 | 348 | create_process_as_user(token, app, cmd, process_security, 349 | thread_security, inherit, hash["creation_flags"], env, 350 | cwd, startinfo, procinfo) 351 | 352 | else 353 | 354 | create_process_with_logon(logon, domain, passwd, LOGON_WITH_PROFILE, 355 | app, cmd, hash["creation_flags"], env, cwd, startinfo, procinfo) 356 | 357 | end 358 | 359 | else 360 | 361 | create_process(app, cmd, process_security, thread_security, inherit, 362 | hash["creation_flags"], env, cwd, startinfo, procinfo) 363 | 364 | end 365 | 366 | # Automatically close the process and thread handles in the 367 | # PROCESS_INFORMATION struct unless explicitly told not to. 368 | if hash["close_handles"] 369 | CloseHandle(procinfo[:hProcess]) 370 | CloseHandle(procinfo[:hThread]) 371 | # Clear these fields so callers don't attempt to close the handle 372 | # which can result in the wrong handle being closed or an 373 | # exception in some circumstances. 374 | procinfo[:hProcess] = 0 375 | procinfo[:hThread] = 0 376 | end 377 | 378 | process = ProcessInfo.new( 379 | procinfo[:hProcess], 380 | procinfo[:hThread], 381 | procinfo[:dwProcessId], 382 | procinfo[:dwThreadId] 383 | ) 384 | 385 | [ process, profile, token ] 386 | end 387 | 388 | # See Process::Constants::WIN32_PROFILETYPE 389 | def logon_has_roaming_profile? 390 | get_profile_type >= 2 391 | end 392 | 393 | def get_profile_type 394 | ptr = FFI::MemoryPointer.new(:uint) 395 | unless GetProfileType(ptr) 396 | raise SystemCallError.new("GetProfileType", FFI.errno) 397 | end 398 | 399 | ptr.read_uint 400 | end 401 | 402 | def load_user_profile(token, profile_ptr) 403 | unless LoadUserProfileW(token, profile_ptr) 404 | raise SystemCallError.new("LoadUserProfileW", FFI.errno) 405 | end 406 | 407 | true 408 | end 409 | 410 | def unload_user_profile(token, profile) 411 | if profile[:hProfile] == 0 412 | warn "\n\nWARNING: Profile not loaded\n" 413 | else 414 | unless UnloadUserProfile(token, profile[:hProfile]) 415 | raise SystemCallError.new("UnloadUserProfile", FFI.errno) 416 | end 417 | end 418 | true 419 | end 420 | 421 | # Retrieves the environment variables for the specified user. 422 | # 423 | # @param env_pointer [Pointer] The environment block is an array of null-terminated Unicode strings. 424 | # @param token [Integer] User token handle. 425 | # @return [Boolean] true if successfully retrieves the environment variables for the specified user. 426 | # 427 | def create_environment_block(env_pointer, token) 428 | unless CreateEnvironmentBlock(env_pointer, token, false) 429 | raise SystemCallError.new("CreateEnvironmentBlock", FFI.errno) 430 | end 431 | 432 | true 433 | end 434 | 435 | # Frees environment variables created by the CreateEnvironmentBlock function. 436 | # 437 | # @param env_pointer [Pointer] The environment block is an array of null-terminated Unicode strings. 438 | # @return [Boolean] true if successfully frees environment variables created by the CreateEnvironmentBlock function. 439 | # 440 | def destroy_environment_block(env_pointer) 441 | unless DestroyEnvironmentBlock(env_pointer) 442 | raise SystemCallError.new("DestroyEnvironmentBlock", FFI.errno) 443 | end 444 | 445 | true 446 | end 447 | 448 | def create_process_as_user(token, app, cmd, process_security, 449 | thread_security, inherit, creation_flags, env, cwd, startinfo, procinfo) 450 | bool = CreateProcessAsUserW( 451 | token, # User token handle 452 | app, # App name 453 | cmd, # Command line 454 | process_security, # Process attributes 455 | thread_security, # Thread attributes 456 | inherit, # Inherit handles 457 | creation_flags, # Creation Flags 458 | env, # Environment 459 | cwd, # Working directory 460 | startinfo, # Startup Info 461 | procinfo # Process Info 462 | ) 463 | 464 | unless bool 465 | msg = case FFI.errno 466 | when ERROR_PRIVILEGE_NOT_HELD 467 | [ 468 | %{CreateProcessAsUserW (User '%s' must hold the 'Replace a process}, 469 | %{level token' and 'Adjust Memory Quotas for a process' permissions.}, 470 | %{Logoff the user after adding this right to make it effective.)}, 471 | ].join(" ") % ::ENV["USERNAME"] 472 | else 473 | "CreateProcessAsUserW failed." 474 | end 475 | raise SystemCallError.new(msg, FFI.errno) 476 | end 477 | end 478 | 479 | def create_process_with_logon(logon, domain, passwd, logon_flags, app, cmd, 480 | creation_flags, env, cwd, startinfo, procinfo) 481 | bool = CreateProcessWithLogonW( 482 | logon, # User 483 | domain, # Domain 484 | passwd, # Password 485 | logon_flags, # Logon flags 486 | app, # App name 487 | cmd, # Command line 488 | creation_flags, # Creation flags 489 | env, # Environment 490 | cwd, # Working directory 491 | startinfo, # Startup Info 492 | procinfo # Process Info 493 | ) 494 | 495 | unless bool 496 | raise SystemCallError.new("CreateProcessWithLogonW", FFI.errno) 497 | end 498 | end 499 | 500 | def create_process(app, cmd, process_security, thread_security, inherit, 501 | creation_flags, env, cwd, startinfo, procinfo) 502 | bool = CreateProcessW( 503 | app, # App name 504 | cmd, # Command line 505 | process_security, # Process attributes 506 | thread_security, # Thread attributes 507 | inherit, # Inherit handles? 508 | creation_flags, # Creation flags 509 | env, # Environment 510 | cwd, # Working directory 511 | startinfo, # Startup Info 512 | procinfo # Process Info 513 | ) 514 | 515 | unless bool 516 | raise SystemCallError.new("CreateProcessW", FFI.errno) 517 | end 518 | end 519 | 520 | def logon_user(user, domain, passwd, type, provider = LOGON32_PROVIDER_DEFAULT) 521 | token = FFI::MemoryPointer.new(:ulong) 522 | 523 | bool = LogonUserW( 524 | user, # User 525 | domain, # Domain 526 | passwd, # Password 527 | type, # Logon Type 528 | provider, # Logon Provider 529 | token # User token handle 530 | ) 531 | 532 | unless bool 533 | if (FFI.errno == ERROR_LOGON_TYPE_NOT_GRANTED) && (type == LOGON32_LOGON_BATCH) 534 | user_utf8 = user.encode( "UTF-8", invalid: :replace, undef: :replace, replace: "" ).delete("\0") 535 | raise SystemCallError.new("LogonUserW (User '#{user_utf8}' must hold 'Log on as a batch job' permissions.)", FFI.errno) 536 | else 537 | raise SystemCallError.new("LogonUserW", FFI.errno) 538 | end 539 | end 540 | 541 | token.read_ulong 542 | end 543 | 544 | def get_windows_station_name 545 | winsta_name = FFI::MemoryPointer.new(:char, 256) 546 | return_size = FFI::MemoryPointer.new(:ulong) 547 | 548 | bool = GetUserObjectInformationA( 549 | GetProcessWindowStation(), # Window station handle 550 | UOI_NAME, # Information to get 551 | winsta_name, # Buffer to receive information 552 | winsta_name.size, # Size of buffer 553 | return_size # Size filled into buffer 554 | ) 555 | 556 | unless bool 557 | raise SystemCallError.new("GetUserObjectInformationA", FFI.errno) 558 | end 559 | 560 | winsta_name.read_string(return_size.read_ulong) 561 | end 562 | 563 | def format_creds_from_hash(hash) 564 | logon = hash["with_logon"].to_wide_string 565 | 566 | if hash["password"] 567 | passwd = hash["password"].to_wide_string 568 | else 569 | raise ArgumentError, "password must be specified if with_logon is used" 570 | end 571 | 572 | if hash["domain"] 573 | domain = hash["domain"].to_wide_string 574 | end 575 | 576 | [ logon, passwd, domain ] 577 | end 578 | 579 | # Retrieves the environment variables for the specified user. 580 | # 581 | # @param token [Integer] User token handle. 582 | # @return env_list [Array] Environment variables of specified user. 583 | # 584 | def retrieve_environment_variables(token) 585 | env_list = [] 586 | env_pointer = FFI::MemoryPointer.new(:pointer) 587 | create_environment_block(env_pointer, token) 588 | str_ptr = env_pointer.read_pointer 589 | offset = 0 590 | loop do 591 | new_str_pointer = str_ptr + offset 592 | break if new_str_pointer.read_string(2) == ENVIRONMENT_BLOCK_ENDS 593 | 594 | environment = new_str_pointer.read_wstring 595 | env_list << environment 596 | offset = offset + environment.length * 2 + 2 597 | end 598 | 599 | # To free the buffer when we have finished with the environment block 600 | destroy_environment_block(str_ptr) 601 | env_list 602 | end 603 | 604 | # Merge environment variables of specified user and current environment variables. 605 | # 606 | # @param fetched_env [Array] environment variables of specified user. 607 | # @param current_env [Array] current environment variables. 608 | # @return [Array] Merged environment variables. 609 | # 610 | def merge_env_variables(fetched_env, current_env) 611 | env_hash_1 = environment_list_to_hash(fetched_env) 612 | env_hash_2 = environment_list_to_hash(current_env) 613 | merged_env = env_hash_2.merge(env_hash_1) 614 | merged_env.map { |k, v| "#{k}=#{v}" } 615 | end 616 | 617 | # Convert an array to a hash. 618 | # 619 | # @param env_var [Array] Environment variables. 620 | # @return [Hash] Converted an array to hash. 621 | # 622 | def environment_list_to_hash(env_var) 623 | Hash[ env_var.map { |pair| pair.split("=", 2) } ] 624 | end 625 | end 626 | end 627 | -------------------------------------------------------------------------------- /mixlib-shellout-universal-mingw-ucrt.gemspec: -------------------------------------------------------------------------------- 1 | gemspec = instance_eval(File.read(File.expand_path("mixlib-shellout.gemspec", __dir__))) 2 | 3 | gemspec.platform = Gem::Platform.new("x64-mingw-ucrt") 4 | gemspec.add_dependency "win32-process", "~> 0.9" 5 | gemspec.add_dependency "wmi-lite", "~> 1.0" 6 | gemspec.add_dependency "ffi-win32-extensions", "~> 1.0.3" 7 | 8 | gemspec 9 | -------------------------------------------------------------------------------- /mixlib-shellout.gemspec: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | lib = File.join(File.expand_path("."), "lib") 4 | $LOAD_PATH.unshift(lib) 5 | 6 | require "mixlib/shellout/version" 7 | 8 | Gem::Specification.new do |s| 9 | s.name = "mixlib-shellout" 10 | s.version = Mixlib::ShellOut::VERSION 11 | s.platform = Gem::Platform::RUBY 12 | s.summary = "Run external commands on Unix or Windows" 13 | s.description = s.summary 14 | s.author = "Chef Software Inc." 15 | s.email = "info@chef.io" 16 | s.homepage = "https://github.com/chef/mixlib-shellout" 17 | s.license = "Apache-2.0" 18 | 19 | s.required_ruby_version = ">= 3.1" 20 | 21 | s.add_dependency "chef-utils" 22 | s.require_path = "lib" 23 | s.files = %w{LICENSE} + Dir.glob("lib/**/*", File::FNM_DOTMATCH).reject { |f| File.directory?(f) } 24 | end 25 | -------------------------------------------------------------------------------- /spec/mixlib/shellout/helper_spec.rb: -------------------------------------------------------------------------------- 1 | require "spec_helper" 2 | require "mixlib/shellout/helper" 3 | require "logger" 4 | 5 | # to use this helper you need to either: 6 | # 1. use mixlib-log which has a trace level 7 | # 2. monkeypatch a trace level into ruby's logger like this 8 | # 3. override the __io_for_live_stream method 9 | # 10 | class Logger 11 | module Severity; TRACE = -1; end 12 | def trace(progname = nil, &block); add(TRACE, nil, progname, &block); end 13 | 14 | def trace?; @level <= TRACE; end 15 | end 16 | 17 | describe Mixlib::ShellOut::Helper, ruby: ">= 2.3" do 18 | class TestClass 19 | include Mixlib::ShellOut::Helper 20 | 21 | # this is a hash-like object 22 | def __config 23 | {} 24 | end 25 | 26 | # this is a train transport connection or nil 27 | def __transport_connection 28 | nil 29 | end 30 | 31 | # this is a logger-like object 32 | def __log 33 | Logger.new(IO::NULL) 34 | end 35 | end 36 | 37 | let(:test_class) { TestClass.new } 38 | 39 | it "works to run a trivial ruby command" do 40 | expect(test_class.shell_out("ruby -e 'exit 0'")).to be_kind_of(Mixlib::ShellOut) 41 | end 42 | end 43 | -------------------------------------------------------------------------------- /spec/mixlib/shellout/windows_spec.rb: -------------------------------------------------------------------------------- 1 | require "spec_helper" 2 | 3 | # FIXME: these are stubby enough unit tests that they almost run under unix, but the 4 | # Mixlib::ShellOut object does not mixin the Windows behaviors when running on unix. 5 | describe "Mixlib::ShellOut::Windows", :windows_only do 6 | 7 | describe "Utils" do 8 | describe ".should_run_under_cmd?" do 9 | subject { Mixlib::ShellOut.new.send(:should_run_under_cmd?, command) } 10 | 11 | def self.with_command(_command, &example) 12 | context "with command: #{_command}" do 13 | let(:command) { _command } 14 | it(&example) 15 | end 16 | end 17 | 18 | context "when unquoted" do 19 | with_command(%q{ruby -e 'prints "foobar"'}) { is_expected.not_to be_truthy } 20 | 21 | # https://github.com/chef/mixlib-shellout/pull/2#issuecomment-4825574 22 | with_command(%q{"C:\Program Files (x86)\Microsoft SDKs\Windows\v7.0A\Bin\NETFX 4.0 Tools\gacutil.exe" /i "C:\Program Files (x86)\NUnit 2.6\bin\framework\nunit.framework.dll"}) { is_expected.not_to be_truthy } 23 | 24 | with_command(%q{ruby -e 'exit 1' | ruby -e 'exit 0'}) { is_expected.to be_truthy } 25 | with_command(%q{ruby -e 'exit 1' > out.txt}) { is_expected.to be_truthy } 26 | with_command(%q{ruby -e 'exit 1' > out.txt 2>&1}) { is_expected.to be_truthy } 27 | with_command(%q{ruby -e 'exit 1' < in.txt}) { is_expected.to be_truthy } 28 | with_command(%q{ruby -e 'exit 1' || ruby -e 'exit 0'}) { is_expected.to be_truthy } 29 | with_command(%q{ruby -e 'exit 1' && ruby -e 'exit 0'}) { is_expected.to be_truthy } 30 | with_command(%q{@echo TRUE}) { is_expected.to be_truthy } 31 | 32 | with_command(%q{echo %PATH%}) { is_expected.to be_truthy } 33 | with_command(%q{run.exe %A}) { is_expected.to be_falsey } 34 | with_command(%q{run.exe B%}) { is_expected.to be_falsey } 35 | with_command(%q{run.exe %A B%}) { is_expected.to be_falsey } 36 | with_command(%q{run.exe %A B% %PATH%}) { is_expected.to be_truthy } 37 | with_command(%q{run.exe %A B% %_PATH%}) { is_expected.to be_truthy } 38 | with_command(%q{run.exe %A B% %PATH_EXT%}) { is_expected.to be_truthy } 39 | with_command(%q{run.exe %A B% %1%}) { is_expected.to be_falsey } 40 | with_command(%q{run.exe %A B% %PATH1%}) { is_expected.to be_truthy } 41 | with_command(%q{run.exe %A B% %_PATH1%}) { is_expected.to be_truthy } 42 | 43 | context "when outside quotes" do 44 | with_command(%q{ruby -e "exit 1" | ruby -e "exit 0"}) { is_expected.to be_truthy } 45 | with_command(%q{ruby -e "exit 1" > out.txt}) { is_expected.to be_truthy } 46 | with_command(%q{ruby -e "exit 1" > out.txt 2>&1}) { is_expected.to be_truthy } 47 | with_command(%q{ruby -e "exit 1" < in.txt}) { is_expected.to be_truthy } 48 | with_command(%q{ruby -e "exit 1" || ruby -e "exit 0"}) { is_expected.to be_truthy } 49 | with_command(%q{ruby -e "exit 1" && ruby -e "exit 0"}) { is_expected.to be_truthy } 50 | with_command(%q{@echo "TRUE"}) { is_expected.to be_truthy } 51 | 52 | context "with unclosed quote" do 53 | with_command(%q{ruby -e "exit 1" | ruby -e "exit 0}) { is_expected.to be_truthy } 54 | with_command(%q{ruby -e "exit 1" > "out.txt}) { is_expected.to be_truthy } 55 | with_command(%q{ruby -e "exit 1" > "out.txt 2>&1}) { is_expected.to be_truthy } 56 | with_command(%q{ruby -e "exit 1" < "in.txt}) { is_expected.to be_truthy } 57 | with_command(%q{ruby -e "exit 1" || "ruby -e "exit 0"}) { is_expected.to be_truthy } 58 | with_command(%q{ruby -e "exit 1" && "ruby -e "exit 0"}) { is_expected.to be_truthy } 59 | with_command(%q{@echo "TRUE}) { is_expected.to be_truthy } 60 | 61 | with_command(%q{echo "%PATH%}) { is_expected.to be_truthy } 62 | with_command(%q{run.exe "%A}) { is_expected.to be_falsey } 63 | with_command(%q{run.exe "B%}) { is_expected.to be_falsey } 64 | with_command(%q{run.exe "%A B%}) { is_expected.to be_falsey } 65 | with_command(%q{run.exe "%A B% %PATH%}) { is_expected.to be_truthy } 66 | with_command(%q{run.exe "%A B% %_PATH%}) { is_expected.to be_truthy } 67 | with_command(%q{run.exe "%A B% %PATH_EXT%}) { is_expected.to be_truthy } 68 | with_command(%q{run.exe "%A B% %1%}) { is_expected.to be_falsey } 69 | with_command(%q{run.exe "%A B% %PATH1%}) { is_expected.to be_truthy } 70 | with_command(%q{run.exe "%A B% %_PATH1%}) { is_expected.to be_truthy } 71 | end 72 | end 73 | end 74 | 75 | context "when quoted" do 76 | with_command(%q{run.exe "ruby -e 'exit 1' || ruby -e 'exit 0'"}) { is_expected.to be_falsey } 77 | with_command(%q{run.exe "ruby -e 'exit 1' > out.txt"}) { is_expected.to be_falsey } 78 | with_command(%q{run.exe "ruby -e 'exit 1' > out.txt 2>&1"}) { is_expected.to be_falsey } 79 | with_command(%q{run.exe "ruby -e 'exit 1' < in.txt"}) { is_expected.to be_falsey } 80 | with_command(%q{run.exe "ruby -e 'exit 1' || ruby -e 'exit 0'"}) { is_expected.to be_falsey } 81 | with_command(%q{run.exe "ruby -e 'exit 1' && ruby -e 'exit 0'"}) { is_expected.to be_falsey } 82 | with_command(%q{run.exe "%PATH%"}) { is_expected.to be_truthy } 83 | with_command(%q{run.exe "%A"}) { is_expected.to be_falsey } 84 | with_command(%q{run.exe "B%"}) { is_expected.to be_falsey } 85 | with_command(%q{run.exe "%A B%"}) { is_expected.to be_falsey } 86 | with_command(%q{run.exe "%A B% %PATH%"}) { is_expected.to be_truthy } 87 | with_command(%q{run.exe "%A B% %_PATH%"}) { is_expected.to be_truthy } 88 | with_command(%q{run.exe "%A B% %PATH_EXT%"}) { is_expected.to be_truthy } 89 | with_command(%q{run.exe "%A B% %1%"}) { is_expected.to be_falsey } 90 | with_command(%q{run.exe "%A B% %PATH1%"}) { is_expected.to be_truthy } 91 | with_command(%q{run.exe "%A B% %_PATH1%"}) { is_expected.to be_truthy } 92 | 93 | context "with unclosed quote" do 94 | with_command(%q{run.exe "ruby -e 'exit 1' || ruby -e 'exit 0'}) { is_expected.to be_falsey } 95 | with_command(%q{run.exe "ruby -e 'exit 1' > out.txt}) { is_expected.to be_falsey } 96 | with_command(%q{run.exe "ruby -e 'exit 1' > out.txt 2>&1}) { is_expected.to be_falsey } 97 | with_command(%q{run.exe "ruby -e 'exit 1' < in.txt}) { is_expected.to be_falsey } 98 | with_command(%q{run.exe "ruby -e 'exit 1' || ruby -e 'exit 0'}) { is_expected.to be_falsey } 99 | with_command(%q{run.exe "ruby -e 'exit 1' && ruby -e 'exit 0'}) { is_expected.to be_falsey } 100 | with_command(%q{run.exe "%PATH%}) { is_expected.to be_truthy } 101 | with_command(%q{run.exe "%A}) { is_expected.to be_falsey } 102 | with_command(%q{run.exe "B%}) { is_expected.to be_falsey } 103 | with_command(%q{run.exe "%A B%}) { is_expected.to be_falsey } 104 | with_command(%q{run.exe "%A B% %PATH%}) { is_expected.to be_truthy } 105 | with_command(%q{run.exe "%A B% %_PATH%}) { is_expected.to be_truthy } 106 | with_command(%q{run.exe "%A B% %PATH_EXT%}) { is_expected.to be_truthy } 107 | with_command(%q{run.exe "%A B% %1%}) { is_expected.to be_falsey } 108 | with_command(%q{run.exe "%A B% %PATH1%}) { is_expected.to be_truthy } 109 | with_command(%q{run.exe "%A B% %_PATH1%}) { is_expected.to be_truthy } 110 | end 111 | end 112 | end 113 | 114 | describe ".kill_process_tree" do 115 | let(:shell_out) { Mixlib::ShellOut.new } 116 | let(:wmi) { Object.new } 117 | let(:wmi_ole_object) { Object.new } 118 | let(:wmi_process) { Object.new } 119 | let(:logger) { Object.new } 120 | 121 | before do 122 | allow(wmi).to receive(:query).and_return([wmi_process]) 123 | allow(wmi_process).to receive(:wmi_ole_object).and_return(wmi_ole_object) 124 | allow(logger).to receive(:debug) 125 | end 126 | 127 | context "with a protected system process in the process tree" do 128 | before do 129 | allow(wmi_ole_object).to receive(:name).and_return("csrss.exe") 130 | allow(wmi_ole_object).to receive(:processid).and_return(100) 131 | end 132 | 133 | it "does not attempt to kill csrss.exe" do 134 | expect(shell_out).to_not receive(:kill_process) 135 | shell_out.send(:kill_process_tree, 200, wmi, logger) 136 | end 137 | end 138 | 139 | context "with a non-system-critical process in the process tree" do 140 | before do 141 | allow(wmi_ole_object).to receive(:name).and_return("blah.exe") 142 | allow(wmi_ole_object).to receive(:processid).and_return(300) 143 | end 144 | 145 | it "does attempt to kill blah.exe" do 146 | expect(shell_out).to receive(:kill_process).with(wmi_process, logger) 147 | expect(shell_out).to receive(:kill_process_tree).with(200, wmi, logger).and_call_original 148 | expect(shell_out).to receive(:kill_process_tree).with(300, wmi, logger) 149 | shell_out.send(:kill_process_tree, 200, wmi, logger) 150 | end 151 | end 152 | end 153 | end 154 | 155 | # Caveat: Private API methods are subject to change without notice. 156 | # Monkeypatch at your own risk. 157 | context "#command_to_run" do 158 | 159 | describe "#command_to_run" do 160 | subject { shell_out.send(:command_to_run, command) } 161 | 162 | # @param cmd [String] command string 163 | # @param filename [String] the pathname to the executable that will be found (nil to have no pathname match) 164 | # @param search [Boolean] false: will setup expectation not to search PATH, true: will setup expectations that it searches the PATH 165 | # @param directory [Boolean] true: will setup an expectation that the search strategy will find a directory 166 | def self.with_command(cmd, filename: nil, search: false, directory: false, &example) 167 | context "with #{cmd}" do 168 | let(:shell_out) { Mixlib::ShellOut.new } 169 | let(:comspec) { 'C:\Windows\system32\cmd.exe' } 170 | let(:command) { cmd } 171 | before do 172 | if search 173 | expect(ENV).to receive(:[]).with("PATH").and_return('C:\Windows\system32') 174 | else 175 | expect(ENV).not_to receive(:[]).with("PATH") 176 | end 177 | allow(ENV).to receive(:[]).with("PATHEXT").and_return(".COM;.EXE;.BAT;.CMD") 178 | allow(ENV).to receive(:[]).with("COMSPEC").and_return(comspec) 179 | allow(File).to receive(:executable?).and_return(false) 180 | if filename 181 | expect(File).to receive(:executable?).with(filename).and_return(true) 182 | expect(File).to receive(:directory?).with(filename).and_return(false) 183 | end 184 | if directory 185 | expect(File).to receive(:executable?).with(cmd).and_return(true) 186 | expect(File).to receive(:directory?).with(cmd).and_return(true) 187 | end 188 | end 189 | it(&example) 190 | end 191 | end 192 | 193 | # quoted and unquoted commands that have correct bat and cmd extensions 194 | with_command("autoexec.bat", filename: "autoexec.bat") do 195 | is_expected.to eql([ comspec, 'cmd /c "autoexec.bat"']) 196 | end 197 | with_command("autoexec.cmd", filename: "autoexec.cmd") do 198 | is_expected.to eql([ comspec, 'cmd /c "autoexec.cmd"']) 199 | end 200 | with_command('"C:\Program Files\autoexec.bat"', filename: 'C:\Program Files\autoexec.bat') do 201 | is_expected.to eql([ comspec, 'cmd /c ""C:\Program Files\autoexec.bat""']) 202 | end 203 | with_command('"C:\Program Files\autoexec.cmd"', filename: 'C:\Program Files\autoexec.cmd') do 204 | is_expected.to eql([ comspec, 'cmd /c ""C:\Program Files\autoexec.cmd""']) 205 | end 206 | 207 | # lookups via PATHEXT 208 | with_command("autoexec", filename: "autoexec.BAT") do 209 | is_expected.to eql([ comspec, 'cmd /c "autoexec"']) 210 | end 211 | with_command("autoexec", filename: "autoexec.CMD") do 212 | is_expected.to eql([ comspec, 'cmd /c "autoexec"']) 213 | end 214 | 215 | # unquoted commands that have "bat" or "cmd" in the wrong place 216 | with_command("autoexecbat", filename: "autoexecbat") do 217 | is_expected.to eql(%w{autoexecbat autoexecbat}) 218 | end 219 | with_command("autoexeccmd", filename: "autoexeccmd") do 220 | is_expected.to eql(%w{autoexeccmd autoexeccmd}) 221 | end 222 | with_command("abattoir.exe", filename: "abattoir.exe") do 223 | is_expected.to eql([ "abattoir.exe", "abattoir.exe" ]) 224 | end 225 | with_command("parse_cmd.exe", filename: "parse_cmd.exe") do 226 | is_expected.to eql([ "parse_cmd.exe", "parse_cmd.exe" ]) 227 | end 228 | 229 | # quoted commands that have "bat" or "cmd" in the wrong place 230 | with_command('"C:\Program Files\autoexecbat"', filename: 'C:\Program Files\autoexecbat') do 231 | is_expected.to eql([ 'C:\Program Files\autoexecbat', '"C:\Program Files\autoexecbat"' ]) 232 | end 233 | with_command('"C:\Program Files\autoexeccmd"', filename: 'C:\Program Files\autoexeccmd') do 234 | is_expected.to eql([ 'C:\Program Files\autoexeccmd', '"C:\Program Files\autoexeccmd"']) 235 | end 236 | with_command('"C:\Program Files\abattoir.exe"', filename: 'C:\Program Files\abattoir.exe') do 237 | is_expected.to eql([ 'C:\Program Files\abattoir.exe', '"C:\Program Files\abattoir.exe"' ]) 238 | end 239 | with_command('"C:\Program Files\parse_cmd.exe"', filename: 'C:\Program Files\parse_cmd.exe') do 240 | is_expected.to eql([ 'C:\Program Files\parse_cmd.exe', '"C:\Program Files\parse_cmd.exe"' ]) 241 | end 242 | 243 | # empty command 244 | with_command(" ") do 245 | expect { subject }.to raise_error(Mixlib::ShellOut::EmptyWindowsCommand) 246 | end 247 | 248 | # extensionless executable 249 | with_command("ping", filename: 'C:\Windows\system32/ping.EXE', search: true) do 250 | is_expected.to eql([ 'C:\Windows\system32/ping.EXE', "ping" ]) 251 | end 252 | 253 | # it ignores directories 254 | with_command("ping", filename: 'C:\Windows\system32/ping.EXE', directory: true, search: true) do 255 | is_expected.to eql([ 'C:\Windows\system32/ping.EXE', "ping" ]) 256 | end 257 | 258 | # https://github.com/chef/mixlib-shellout/pull/2 with bat file 259 | with_command('"C:\Program Files\Application\Start.bat"', filename: 'C:\Program Files\Application\Start.bat') do 260 | is_expected.to eql([ comspec, 'cmd /c ""C:\Program Files\Application\Start.bat""' ]) 261 | end 262 | with_command('"C:\Program Files\Application\Start.bat" arguments', filename: 'C:\Program Files\Application\Start.bat') do 263 | is_expected.to eql([ comspec, 'cmd /c ""C:\Program Files\Application\Start.bat" arguments"' ]) 264 | end 265 | with_command('"C:\Program Files\Application\Start.bat" /i "C:\Program Files (x86)\NUnit 2.6\bin\framework\nunit.framework.dll"', filename: 'C:\Program Files\Application\Start.bat') do 266 | is_expected.to eql([ comspec, 'cmd /c ""C:\Program Files\Application\Start.bat" /i "C:\Program Files (x86)\NUnit 2.6\bin\framework\nunit.framework.dll""' ]) 267 | end 268 | 269 | # https://github.com/chef/mixlib-shellout/pull/2 with cmd file 270 | with_command('"C:\Program Files\Application\Start.cmd"', filename: 'C:\Program Files\Application\Start.cmd') do 271 | is_expected.to eql([ comspec, 'cmd /c ""C:\Program Files\Application\Start.cmd""' ]) 272 | end 273 | with_command('"C:\Program Files\Application\Start.cmd" arguments', filename: 'C:\Program Files\Application\Start.cmd') do 274 | is_expected.to eql([ comspec, 'cmd /c ""C:\Program Files\Application\Start.cmd" arguments"' ]) 275 | end 276 | with_command('"C:\Program Files\Application\Start.cmd" /i "C:\Program Files (x86)\NUnit 2.6\bin\framework\nunit.framework.dll"', filename: 'C:\Program Files\Application\Start.cmd') do 277 | is_expected.to eql([ comspec, 'cmd /c ""C:\Program Files\Application\Start.cmd" /i "C:\Program Files (x86)\NUnit 2.6\bin\framework\nunit.framework.dll""' ]) 278 | end 279 | 280 | # https://github.com/chef/mixlib-shellout/pull/2 with unquoted exe file 281 | with_command('C:\RUBY192\bin\ruby.exe', filename: 'C:\RUBY192\bin\ruby.exe') do 282 | is_expected.to eql([ 'C:\RUBY192\bin\ruby.exe', 'C:\RUBY192\bin\ruby.exe' ]) 283 | end 284 | with_command('C:\RUBY192\bin\ruby.exe arguments', filename: 'C:\RUBY192\bin\ruby.exe') do 285 | is_expected.to eql([ 'C:\RUBY192\bin\ruby.exe', 'C:\RUBY192\bin\ruby.exe arguments' ]) 286 | end 287 | with_command('C:\RUBY192\bin\ruby.exe -e "print \'fee fie foe fum\'"', filename: 'C:\RUBY192\bin\ruby.exe') do 288 | is_expected.to eql([ 'C:\RUBY192\bin\ruby.exe', 'C:\RUBY192\bin\ruby.exe -e "print \'fee fie foe fum\'"' ]) 289 | end 290 | 291 | # https://github.com/chef/mixlib-shellout/pull/2 with quoted exe file 292 | exe_with_spaces = 'C:\Program Files (x86)\Microsoft SDKs\Windows\v7.0A\Bin\NETFX 4.0 Tools\gacutil.exe' 293 | with_command("\"#{exe_with_spaces}\"", filename: exe_with_spaces) do 294 | is_expected.to eql([ exe_with_spaces, "\"#{exe_with_spaces}\"" ]) 295 | end 296 | with_command("\"#{exe_with_spaces}\" arguments", filename: exe_with_spaces) do 297 | is_expected.to eql([ exe_with_spaces, "\"#{exe_with_spaces}\" arguments" ]) 298 | end 299 | long_options = "/i \"C:\Program Files (x86)\NUnit 2.6\bin\framework\nunit.framework.dll\"" 300 | with_command("\"#{exe_with_spaces}\" #{long_options}", filename: exe_with_spaces) do 301 | is_expected.to eql([ exe_with_spaces, "\"#{exe_with_spaces}\" #{long_options}" ]) 302 | end 303 | 304 | # shell built in 305 | with_command("copy thing1.txt thing2.txt", search: true) do 306 | is_expected.to eql([ comspec, 'cmd /c "copy thing1.txt thing2.txt"' ]) 307 | end 308 | end 309 | end 310 | 311 | context "#combine_args" do 312 | let(:shell_out) { Mixlib::ShellOut.new } 313 | subject { shell_out.send(:combine_args, *largs) } 314 | 315 | def self.with_args(*args, &example) 316 | context "with command #{args}" do 317 | let(:largs) { args } 318 | it(&example) 319 | end 320 | end 321 | 322 | with_args("echo", "%PATH%") do 323 | is_expected.to eql(%q{echo %PATH%}) 324 | end 325 | 326 | with_args("echo %PATH%") do 327 | is_expected.to eql(%q{echo %PATH%}) 328 | end 329 | 330 | # Note carefully for the following that single quotes in ruby support '\\' as an escape sequence for a single 331 | # literal backslash. It is not mandatory to always use this since '\d' does not escape the 'd' and is literally 332 | # a backlash followed by an 'd'. However, in the following all backslashes are escaped for consistency. Otherwise 333 | # it becomes prohibitively confusing to track when you need and do not need the escape the backslash (particularly 334 | # when the literal string has a trailing backslash such that '\\' must be used instead of '\' which would escape 335 | # the intended terminating single quote or %q{\} which escapes the terminating delimiter). 336 | 337 | with_args("child.exe", "argument1", "argument 2", '\\some\\path with\\spaces') do 338 | is_expected.to eql('child.exe argument1 "argument 2" "\\some\\path with\\spaces"') 339 | end 340 | 341 | with_args("child.exe", "argument1", 'she said, "you had me at hello"', '\\some\\path with\\spaces') do 342 | is_expected.to eql('child.exe argument1 "she said, \\"you had me at hello\\"" "\\some\\path with\\spaces"') 343 | end 344 | 345 | with_args("child.exe", "argument1", 'argument\\\\"2\\\\"', "argument3", "argument4") do 346 | is_expected.to eql('child.exe argument1 "argument\\\\\\\\\\"2\\\\\\\\\\"" argument3 argument4') 347 | end 348 | 349 | with_args("child.exe", '\\some\\directory with\\spaces\\', "argument2") do 350 | is_expected.to eql('child.exe "\\some\\directory with\\spaces\\\\" argument2') 351 | end 352 | 353 | with_args("child.exe", '\\some\\directory with\\\\\\spaces\\\\\\', "argument2") do 354 | is_expected.to eql('child.exe "\\some\\directory with\\\\\\spaces\\\\\\\\\\\\" argument2') 355 | end 356 | end 357 | 358 | context "#run_command" do 359 | let(:shell_out) { Mixlib::ShellOut.new(*largs) } 360 | subject { shell_out.send(:run_command) } 361 | 362 | def self.with_args(*args, &example) 363 | context "with command #{args}" do 364 | let(:largs) { args } 365 | it(&example) 366 | end 367 | end 368 | 369 | with_args("echo", "FOO") do 370 | is_expected.not_to be_error 371 | end 372 | 373 | # Test box is going to have to have c:\program files on it, which is perhaps brittle in principle, but 374 | # I don't know enough windows to come up with a less brittle test. Fix it if you've got a better idea. 375 | # The tests need to fail though if the argument is not quoted correctly so using `echo` would be poor 376 | # because `echo FOO BAR` and `echo "FOO BAR"` aren't any different. 377 | 378 | with_args('dir c:\\program files') do 379 | is_expected.to be_error 380 | end 381 | 382 | with_args('dir "c:\\program files"') do 383 | is_expected.not_to be_error 384 | end 385 | 386 | with_args("dir", 'c:\\program files') do 387 | is_expected.not_to be_error 388 | end 389 | 390 | with_args("dir", 'c:\\program files\\') do 391 | is_expected.not_to be_error 392 | end 393 | end 394 | end 395 | -------------------------------------------------------------------------------- /spec/mixlib/shellout_spec.rb: -------------------------------------------------------------------------------- 1 | require "spec_helper" 2 | require "etc" 3 | require "logger" 4 | require "timeout" 5 | 6 | describe Mixlib::ShellOut do 7 | let(:shell_cmd) { options ? shell_cmd_with_options : shell_cmd_without_options } 8 | let(:executed_cmd) { shell_cmd.tap(&:run_command) } 9 | let(:stdout) { executed_cmd.stdout } 10 | let(:stderr) { executed_cmd.stderr } 11 | let(:chomped_stdout) { stdout.chomp } 12 | let(:stripped_stdout) { stdout.strip } 13 | let(:exit_status) { executed_cmd.status.exitstatus } 14 | 15 | let(:shell_cmd_without_options) { Mixlib::ShellOut.new(cmd) } 16 | let(:shell_cmd_with_options) { Mixlib::ShellOut.new(cmd, options) } 17 | let(:cmd) { ruby_eval.call(ruby_code) } 18 | let(:ruby_code) { raise "define let(:ruby_code)" } 19 | let(:options) { nil } 20 | 21 | let(:ruby_eval) { lambda { |code| "ruby -e '#{code}'" } } 22 | 23 | context "when instantiating" do 24 | subject { shell_cmd } 25 | let(:cmd) { "apt-get install chef" } 26 | 27 | it "should set the command" do 28 | expect(subject.command).to eql(cmd) 29 | end 30 | 31 | context "with default settings" do 32 | describe "#cwd" do 33 | subject { super().cwd } 34 | it { is_expected.to be_nil } 35 | end 36 | 37 | describe "#user" do 38 | subject { super().user } 39 | it { is_expected.to be_nil } 40 | end 41 | 42 | describe "#with_logon" do 43 | subject { super().with_logon } 44 | it { is_expected.to be_nil } 45 | end 46 | 47 | describe "#login" do 48 | subject { super().login } 49 | it { is_expected.to be_nil } 50 | end 51 | 52 | describe "#domain" do 53 | subject { super().domain } 54 | it { is_expected.to be_nil } 55 | end 56 | 57 | describe "#password" do 58 | subject { super().password } 59 | it { is_expected.to be_nil } 60 | end 61 | 62 | describe "#group" do 63 | subject { super().group } 64 | it { is_expected.to be_nil } 65 | end 66 | 67 | describe "#umask" do 68 | subject { super().umask } 69 | it { is_expected.to be_nil } 70 | end 71 | 72 | describe "#timeout" do 73 | subject { super().timeout } 74 | it { is_expected.to eql(600) } 75 | end 76 | 77 | describe "#valid_exit_codes" do 78 | subject { super().valid_exit_codes } 79 | it { is_expected.to eql([0]) } 80 | end 81 | 82 | describe "#live_stream" do 83 | subject { super().live_stream } 84 | it { is_expected.to be_nil } 85 | end 86 | 87 | describe "#input" do 88 | subject { super().input } 89 | it { is_expected.to be_nil } 90 | end 91 | 92 | describe "#cgroup" do 93 | subject { super().input } 94 | it { is_expected.to be_nil } 95 | end 96 | 97 | it "should not set any default environmental variables" do 98 | expect(shell_cmd.environment).to eq({}) 99 | end 100 | end 101 | 102 | context "when setting accessors" do 103 | subject { shell_cmd.send(accessor) } 104 | 105 | let(:shell_cmd) { blank_shell_cmd.tap(&with_overrides) } 106 | let(:blank_shell_cmd) { Mixlib::ShellOut.new("apt-get install chef") } 107 | let(:with_overrides) { lambda { |shell_cmd| shell_cmd.send("#{accessor}=", value) } } 108 | 109 | context "when setting user" do 110 | let(:accessor) { :user } 111 | let(:value) { "root" } 112 | 113 | it "should set the user" do 114 | is_expected.to eql(value) 115 | end 116 | 117 | # TODO add :unix_only 118 | context "with an integer value for user" do 119 | let(:value) { 0 } 120 | it "should use the user-supplied uid" do 121 | expect(shell_cmd.uid).to eql(value) 122 | end 123 | end 124 | 125 | # TODO add :unix_only 126 | context "with string value for user" do 127 | let(:value) { username } 128 | 129 | let(:username) { user_info.name } 130 | let(:expected_uid) { user_info.uid } 131 | let(:user_info) { Etc.getpwent } 132 | 133 | it "should compute the uid of the user", :unix_only do 134 | expect(shell_cmd.uid).to eql(expected_uid) 135 | end 136 | end 137 | end 138 | 139 | context "when setting with_logon" do 140 | let(:accessor) { :with_logon } 141 | let(:value) { "root" } 142 | 143 | it "should set the with_logon" do 144 | is_expected.to eql(value) 145 | end 146 | end 147 | 148 | context "when setting login" do 149 | let(:accessor) { :login } 150 | let(:value) { true } 151 | 152 | it "should set the login" do 153 | is_expected.to eql(value) 154 | end 155 | end 156 | 157 | context "when setting domain" do 158 | let(:accessor) { :domain } 159 | let(:value) { "localhost" } 160 | 161 | it "should set the domain" do 162 | is_expected.to eql(value) 163 | end 164 | end 165 | 166 | context "when setting password" do 167 | let(:accessor) { :password } 168 | let(:value) { "vagrant" } 169 | 170 | it "should set the password" do 171 | is_expected.to eql(value) 172 | end 173 | end 174 | 175 | context "when setting group" do 176 | let(:accessor) { :group } 177 | let(:value) { "wheel" } 178 | 179 | it "should set the group" do 180 | is_expected.to eql(value) 181 | end 182 | 183 | # TODO add :unix_only 184 | context "with integer value for group" do 185 | let(:value) { 0 } 186 | it "should use the user-supplied gid" do 187 | expect(shell_cmd.gid).to eql(value) 188 | end 189 | end 190 | 191 | context "with string value for group" do 192 | let(:value) { groupname } 193 | let(:groupname) { group_info.name } 194 | let(:expected_gid) { group_info.gid } 195 | let(:group_info) { Etc.getgrent } 196 | 197 | it "should compute the gid of the user", :unix_only do 198 | expect(shell_cmd.gid).to eql(expected_gid) 199 | end 200 | end 201 | end 202 | 203 | context "when setting the umask" do 204 | let(:accessor) { :umask } 205 | 206 | context "with octal integer" do 207 | let(:value) { 007555 } 208 | 209 | it "should set the umask" do 210 | is_expected.to eql(value) 211 | end 212 | end 213 | 214 | context "with decimal integer" do 215 | let(:value) { 2925 } 216 | 217 | it "should sets the umask" do 218 | is_expected.to eql(005555) 219 | end 220 | end 221 | 222 | context "with string" do 223 | let(:value) { "7777" } 224 | 225 | it "should sets the umask" do 226 | is_expected.to eql(007777) 227 | end 228 | end 229 | end 230 | 231 | context "when setting read timeout" do 232 | let(:accessor) { :timeout } 233 | let(:value) { 10 } 234 | 235 | it "should set the read timeout" do 236 | is_expected.to eql(value) 237 | end 238 | end 239 | 240 | context "when setting valid exit codes" do 241 | let(:accessor) { :valid_exit_codes } 242 | let(:value) { [0, 23, 42] } 243 | 244 | it "should set the valid exit codes" do 245 | is_expected.to eql(value) 246 | end 247 | end 248 | 249 | context "when setting a live stream" do 250 | let(:accessor) { :live_stream } 251 | let(:value) { stream } 252 | let(:stream) { StringIO.new } 253 | 254 | before(:each) do 255 | shell_cmd.live_stream = stream 256 | end 257 | 258 | it "live stream should return the stream used for live stdout and live stderr" do 259 | expect(shell_cmd.live_stream).to eql(stream) 260 | end 261 | 262 | it "should set the live stdout stream" do 263 | expect(shell_cmd.live_stderr).to eql(stream) 264 | end 265 | 266 | it "should set the live stderr stream" do 267 | expect(shell_cmd.live_stderr).to eql(stream) 268 | end 269 | end 270 | 271 | context "when setting the live stdout and live stderr streams separately" do 272 | let(:accessor) { :live_stream } 273 | let(:stream) { StringIO.new } 274 | let(:value) { stream } 275 | let(:stdout_stream) { StringIO.new } 276 | let(:stderr_stream) { StringIO.new } 277 | 278 | before(:each) do 279 | shell_cmd.live_stdout = stdout_stream 280 | shell_cmd.live_stderr = stderr_stream 281 | end 282 | 283 | it "live_stream should return nil" do 284 | expect(shell_cmd.live_stream).to be_nil 285 | end 286 | 287 | it "should set the live stdout" do 288 | expect(shell_cmd.live_stdout).to eql(stdout_stream) 289 | end 290 | 291 | it "should set the live stderr" do 292 | expect(shell_cmd.live_stderr).to eql(stderr_stream) 293 | end 294 | end 295 | 296 | context "when setting a live stream and then overriding the live stderr" do 297 | let(:accessor) { :live_stream } 298 | let(:value) { stream } 299 | let(:stream) { StringIO.new } 300 | 301 | before(:each) do 302 | shell_cmd.live_stdout = stream 303 | shell_cmd.live_stderr = nil 304 | end 305 | 306 | it "should return nil" do 307 | is_expected.to be_nil 308 | end 309 | 310 | it "should set the live stdout" do 311 | expect(shell_cmd.live_stdout).to eql(stream) 312 | end 313 | 314 | it "should set the live stderr" do 315 | expect(shell_cmd.live_stderr).to eql(nil) 316 | end 317 | end 318 | 319 | context "when setting an input" do 320 | let(:accessor) { :input } 321 | let(:value) { "Random content #{rand(1000000)}" } 322 | 323 | it "should set the input" do 324 | is_expected.to eql(value) 325 | end 326 | end 327 | 328 | context "when setting cgroup" do 329 | let(:accessor) { :cgroup } 330 | let(:value) { "test" } 331 | 332 | it "should set the cgroup" do 333 | is_expected.to eql(value) 334 | end 335 | end 336 | end 337 | 338 | context "testing login", :unix_only do 339 | subject { shell_cmd } 340 | let(:uid) { 1005 } 341 | let(:gid) { 1002 } 342 | let(:shell) { "/bin/money" } 343 | let(:dir) { "/home/castle" } 344 | let(:path) { "/sbin:/bin:/usr/sbin:/usr/bin" } 345 | before :each do 346 | shell_cmd.login = true 347 | catbert_user = double("Etc::Passwd", name: "catbert", passwd: "x", uid: 1005, gid: 1002, gecos: "Catbert,,,", dir: "/home/castle", shell: "/bin/money") 348 | group_double = [ 349 | double("Etc::Group", name: "catbert", passwd: "x", gid: 1002, mem: []), 350 | double("Etc::Group", name: "sudo", passwd: "x", gid: 52, mem: ["catbert"]), 351 | double("Etc::Group", name: "rats", passwd: "x", gid: 43, mem: ["ratbert"]), 352 | double("Etc::Group", name: "dilbertpets", passwd: "x", gid: 700, mem: %w{catbert ratbert}), 353 | ] 354 | allow(Etc).to receive(:getpwuid).with(1005) { catbert_user } 355 | allow(Etc).to receive(:getpwnam).with("catbert") { catbert_user } 356 | allow(shell_cmd).to receive(:all_seconderies) { group_double } 357 | end 358 | 359 | # Setting the user by name should change the uid 360 | context "when setting user by name" do 361 | before(:each) { shell_cmd.user = "catbert" } 362 | describe "#uid" do 363 | subject { super().uid } 364 | it { is_expected.to eq(uid) } 365 | end 366 | end 367 | 368 | context "when setting user by id" do 369 | before(:each) { shell_cmd.user = uid } 370 | # Setting the user by uid should change the uid 371 | # it 'should set the uid' do 372 | 373 | describe "#uid" do 374 | subject { super().uid } 375 | it { is_expected.to eq(uid) } 376 | end 377 | # end 378 | # Setting the user without a different gid should change the gid to 1002 379 | 380 | describe "#gid" do 381 | subject { super().gid } 382 | it { is_expected.to eq(gid) } 383 | end 384 | # Setting the user and the group (to 43) should change the gid to 43 385 | context "when setting the group manually" do 386 | before(:each) { shell_cmd.group = 43 } 387 | 388 | describe "#gid" do 389 | subject { super().gid } 390 | it { is_expected.to eq(43) } 391 | end 392 | end 393 | # Setting the user should set the env variables 394 | describe "#process_environment" do 395 | subject { super().process_environment } 396 | it { is_expected.to eq({ "HOME" => dir, "SHELL" => shell, "USER" => "catbert", "LOGNAME" => "catbert", "PATH" => path, "IFS" => "\t\n" }) } 397 | end 398 | # Setting the user with overriding env variables should override 399 | context "when adding environment variables" do 400 | before(:each) { shell_cmd.environment = { "PATH" => "/lord:/of/the/dance", "CUSTOM" => "costume" } } 401 | it "should preserve custom variables" do 402 | expect(shell_cmd.process_environment["PATH"]).to eq("/lord:/of/the/dance") 403 | end 404 | # Setting the user with additional env variables should have both 405 | it "should allow new variables" do 406 | expect(shell_cmd.process_environment["CUSTOM"]).to eq("costume") 407 | end 408 | end 409 | # Setting the user should set secondary groups 410 | describe "#sgids" do 411 | subject { super().sgids } 412 | it { is_expected.to match_array([52, 700]) } 413 | end 414 | end 415 | # Setting login with user should throw errors 416 | context "when not setting a user id" do 417 | it "should fail showing an error" do 418 | expect { Mixlib::ShellOut.new("hostname", { login: true }) }.to raise_error(Mixlib::ShellOut::InvalidCommandOption) 419 | end 420 | end 421 | end 422 | 423 | context "with options hash" do 424 | let(:cmd) { "brew install couchdb" } 425 | let(:options) do 426 | { cwd:, user:, login: true, domain:, password:, group:, 427 | umask:, timeout:, environment:, returns: valid_exit_codes, 428 | live_stream: stream, input:, cgroup: } 429 | end 430 | 431 | let(:cwd) { "/tmp" } 432 | let(:user) { "toor" } 433 | let(:with_logon) { user } 434 | let(:login) { true } 435 | let(:domain) { "localhost" } 436 | let(:password) { "vagrant" } 437 | let(:group) { "wheel" } 438 | let(:umask) { "2222" } 439 | let(:timeout) { 5 } 440 | let(:environment) { { "RUBY_OPTS" => "-w" } } 441 | let(:valid_exit_codes) { [ 0, 1, 42 ] } 442 | let(:stream) { StringIO.new } 443 | let(:input) { 1.upto(10).map { "Data #{rand(100000)}" }.join("\n") } 444 | let(:cgroup) { "test" } 445 | 446 | it "should set the working directory" do 447 | expect(shell_cmd.cwd).to eql(cwd) 448 | end 449 | 450 | it "should set the user" do 451 | expect(shell_cmd.user).to eql(user) 452 | end 453 | 454 | it "should set the with_logon" do 455 | expect(shell_cmd.with_logon).to eql(with_logon) 456 | end 457 | 458 | it "should set the login" do 459 | expect(shell_cmd.login).to eql(login) 460 | end 461 | 462 | it "should set the domain" do 463 | expect(shell_cmd.domain).to eql(domain) 464 | end 465 | 466 | it "should set the password" do 467 | expect(shell_cmd.password).to eql(password) 468 | end 469 | 470 | it "should set the group" do 471 | expect(shell_cmd.group).to eql(group) 472 | end 473 | 474 | it "should set the umask" do 475 | expect(shell_cmd.umask).to eql(002222) 476 | end 477 | 478 | it "should set the timeout" do 479 | expect(shell_cmd.timeout).to eql(timeout) 480 | end 481 | 482 | it "should add environment settings to the default" do 483 | expect(shell_cmd.environment).to eql({ "RUBY_OPTS" => "-w" }) 484 | end 485 | 486 | context "when setting custom environments" do 487 | context "when setting the :env option" do 488 | let(:options) { { env: environment } } 489 | 490 | it "should also set the environment" do 491 | expect(shell_cmd.environment).to eql({ "RUBY_OPTS" => "-w" }) 492 | end 493 | end 494 | 495 | context "when setting environments with symbols" do 496 | let(:options) { { environment: { SYMBOL: "cymbal" } } } 497 | 498 | it "should also set the enviroment" do 499 | expect(shell_cmd.environment).to eql({ "SYMBOL" => "cymbal" }) 500 | end 501 | end 502 | 503 | context "when :environment is set to nil" do 504 | let(:options) { { environment: nil } } 505 | 506 | it "should not set any environment" do 507 | expect(shell_cmd.environment).to eq({}) 508 | end 509 | end 510 | 511 | context "when :env is set to nil" do 512 | let(:options) { { env: nil } } 513 | 514 | it "should not set any environment" do 515 | expect(shell_cmd.environment).to eql({}) 516 | end 517 | end 518 | end 519 | 520 | it "should set valid exit codes" do 521 | expect(shell_cmd.valid_exit_codes).to eql(valid_exit_codes) 522 | end 523 | 524 | it "should set the live stream" do 525 | expect(shell_cmd.live_stream).to eql(stream) 526 | end 527 | 528 | it "should set the input" do 529 | expect(shell_cmd.input).to eql(input) 530 | end 531 | 532 | it "should set the cgroup" do 533 | expect(shell_cmd.cgroup).to eql(cgroup) 534 | end 535 | 536 | context "with an invalid option" do 537 | let(:options) { { frab: :job } } 538 | let(:invalid_option_exception) { Mixlib::ShellOut::InvalidCommandOption } 539 | let(:exception_message) { "option ':frab' is not a valid option for Mixlib::ShellOut" } 540 | 541 | it "should raise InvalidCommandOPtion" do 542 | expect { shell_cmd }.to raise_error(invalid_option_exception, exception_message) 543 | end 544 | end 545 | end 546 | 547 | context "with array of command and args" do 548 | let(:cmd) { [ "ruby", "-e", %q{'puts "hello"'} ] } 549 | 550 | context "without options" do 551 | let(:options) { nil } 552 | 553 | it "should set the command to the array of command and args" do 554 | expect(shell_cmd.command).to eql(cmd) 555 | end 556 | end 557 | 558 | context "with options" do 559 | let(:options) { { cwd: "/tmp", user: "nobody", password: "something" } } 560 | 561 | it "should set the command to the array of command and args" do 562 | expect(shell_cmd.command).to eql(cmd) 563 | end 564 | 565 | it "should evaluate the options" do 566 | expect(shell_cmd.cwd).to eql("/tmp") 567 | expect(shell_cmd.user).to eql("nobody") 568 | expect(shell_cmd.password).to eql("something") 569 | end 570 | end 571 | end 572 | end 573 | 574 | context "when executing the command" do 575 | let(:dir) { Dir.mktmpdir } 576 | let(:dump_file) { "#{dir}/out.txt" } 577 | let(:dump_file_content) { stdout; IO.read(dump_file) } 578 | 579 | context "with a current working directory" do 580 | subject { File.expand_path(chomped_stdout) } 581 | let(:fully_qualified_cwd) { File.expand_path(cwd) } 582 | let(:options) { { cwd: } } 583 | 584 | context "when running under Unix", :unix_only do 585 | # Use /bin for tests only if it is not a symlink. Some 586 | # distributions (e.g. Fedora) symlink it to /usr/bin 587 | let(:cwd) { File.symlink?("/bin") ? "/tmp" : "/bin" } 588 | let(:cmd) { "pwd" } 589 | 590 | it "should chdir to the working directory" do 591 | is_expected.to eql(fully_qualified_cwd) 592 | end 593 | end 594 | 595 | context "when running under Windows", :windows_only do 596 | let(:cwd) { Dir.tmpdir } 597 | let(:cmd) { "echo %cd%" } 598 | 599 | it "should chdir to the working directory" do 600 | is_expected.to eql(fully_qualified_cwd) 601 | end 602 | end 603 | end 604 | 605 | context "when handling locale" do 606 | before do 607 | @original_lc_all = ENV["LC_ALL"] 608 | ENV["LC_ALL"] = "en_US.UTF-8" 609 | end 610 | after do 611 | ENV["LC_ALL"] = @original_lc_all 612 | end 613 | 614 | subject { stripped_stdout } 615 | let(:cmd) { ECHO_LC_ALL } 616 | let(:options) { { environment: { "LC_ALL" => locale } } } 617 | 618 | context "without specifying environment" do 619 | let(:options) { nil } 620 | it "should no longer use the C locale by default" do 621 | is_expected.to eql("en_US.UTF-8") 622 | end 623 | end 624 | 625 | context "with locale" do 626 | let(:locale) { "es" } 627 | 628 | it "should use the requested locale" do 629 | is_expected.to eql(locale) 630 | end 631 | end 632 | 633 | context "with LC_ALL set to nil" do 634 | let(:locale) { nil } 635 | 636 | context "when running under Unix", :unix_only do 637 | it "should unset the process's locale" do 638 | is_expected.to eql("") 639 | end 640 | end 641 | 642 | context "when running under Windows", :windows_only do 643 | it "should unset process's locale" do 644 | is_expected.to eql("%LC_ALL%") 645 | end 646 | end 647 | end 648 | end 649 | 650 | context "when running under Windows", :windows_only do 651 | let(:cmd) { "%windir%/system32/whoami.exe" } 652 | let(:running_user) { shell_cmd.run_command.stdout.strip.downcase } 653 | 654 | context "when no user is set" do 655 | # Need to adjust the username and domain if running as local system 656 | # to match how whoami returns the information 657 | 658 | it "should run as current user" do 659 | if ENV["USERNAME"] == "#{ENV["COMPUTERNAME"]}$" 660 | expected_user = "nt authority\\system" 661 | else 662 | expected_user = "#{ENV["USERDOMAIN"].downcase}\\#{ENV["USERNAME"].downcase}" 663 | end 664 | expect(running_user).to eql(expected_user) 665 | end 666 | end 667 | 668 | context "when user is specified" do 669 | before do 670 | expect(system("net user #{user} #{password} /add")).to eq(true) 671 | end 672 | 673 | after do 674 | expect(system("net user #{user} /delete")).to eq(true) 675 | end 676 | 677 | let(:user) { "testuser" } 678 | let(:password) { "testpass1!" } 679 | let(:options) { { user:, password: } } 680 | 681 | it "should run as specified user" do 682 | expect(running_user).to eql("#{ENV["COMPUTERNAME"].downcase}\\#{user}") 683 | end 684 | 685 | context "when an alternate user is passed" do 686 | let(:env_list) { ["ALLUSERSPROFILE=C:\\ProgramData", "TEMP=C:\\Windows\\TEMP", "TMP=C:\\Windows\\TEMP", "USERDOMAIN=WIN-G06ENRTTKF9", "USERNAME=testuser", "USERPROFILE=C:\\Users\\Default", "windir=C:\\Windows"] } 687 | let(:current_env) { ["ALLUSERSPROFILE=C:\\ProgramData", "TEMP=C:\\Users\\ADMINI~1\\AppData\\Local\\Temp\\2", "TMP=C:\\Users\\ADMINI~1\\AppData\\Local\\Temp\\2", "USER=Administrator", "USERDOMAIN=WIN-G06ENRTTKF9", "USERDOMAIN_ROAMINGPROFILE=WIN-G06ENRTTKF9", "USERNAME=Administrator", "USERPROFILE=C:\\Users\\Administrator", "windir=C:\\Windows"] } 688 | let(:merged_env) { ["ALLUSERSPROFILE=C:\\ProgramData", "TEMP=C:\\Windows\\TEMP", "TMP=C:\\Windows\\TEMP", "USER=Administrator", "USERDOMAIN=WIN-G06ENRTTKF9", "USERDOMAIN_ROAMINGPROFILE=WIN-G06ENRTTKF9", "USERNAME=testuser", "USERPROFILE=C:\\Users\\Default", "windir=C:\\Windows"] } 689 | let(:converted) { { "ALLUSERSPROFILE" => "C:\\ProgramData", "TEMP" => "C:\\Windows\\TEMP", "TMP" => "C:\\Windows\\TEMP", "USERDOMAIN" => "WIN-G06ENRTTKF9", "USERNAME" => "testuser", "USERPROFILE" => "C:\\Users\\Default", "windir" => "C:\\Windows" } } 690 | 691 | it "merge environment variables" do 692 | expect(Process.merge_env_variables(env_list, current_env)).to eql(merged_env) 693 | end 694 | 695 | it "Convert an array to a hash" do 696 | expect(Process.environment_list_to_hash(env_list)).to eql(converted) 697 | end 698 | end 699 | 700 | context "when :elevated => true" do 701 | context "when user and password are passed" do 702 | let(:options) { { user:, password:, elevated: true } } 703 | 704 | it "raises permission related error" do 705 | expect { running_user }.to raise_error(/the user has not been granted the requested logon type at this computer/) 706 | end 707 | end 708 | 709 | context "when user and password are not passed" do 710 | let(:options) { { elevated: true } } 711 | 712 | it "raises error" do 713 | expect { running_user }.to raise_error("`elevated` option should be passed only with `username` and `password`.") 714 | end 715 | end 716 | end 717 | end 718 | end 719 | 720 | context "with a live stream" do 721 | let(:stream) { StringIO.new } 722 | let(:ruby_code) { '$stdout.puts "hello"; $stderr.puts "world"' } 723 | let(:options) { { live_stream: stream } } 724 | 725 | it "should copy the child's stdout to the live stream" do 726 | shell_cmd.run_command 727 | expect(stream.string).to include("hello#{LINE_ENDING}") 728 | end 729 | 730 | context "with default live stderr" do 731 | it "should copy the child's stderr to the live stream" do 732 | shell_cmd.run_command 733 | expect(stream.string).to include("world#{LINE_ENDING}") 734 | end 735 | end 736 | 737 | context "without live stderr" do 738 | it "should not copy the child's stderr to the live stream" do 739 | shell_cmd.live_stderr = nil 740 | shell_cmd.run_command 741 | expect(stream.string).not_to include("world#{LINE_ENDING}") 742 | end 743 | end 744 | 745 | context "with a separate live stderr" do 746 | let(:stderr_stream) { StringIO.new } 747 | 748 | it "should not copy the child's stderr to the live stream" do 749 | shell_cmd.live_stderr = stderr_stream 750 | shell_cmd.run_command 751 | expect(stream.string).not_to include("world#{LINE_ENDING}") 752 | end 753 | 754 | it "should copy the child's stderr to the live stderr stream" do 755 | shell_cmd.live_stderr = stderr_stream 756 | shell_cmd.run_command 757 | expect(stderr_stream.string).to include("world#{LINE_ENDING}") 758 | end 759 | end 760 | end 761 | 762 | context "with an input" do 763 | subject { stdout } 764 | 765 | let(:input) { "hello" } 766 | let(:ruby_code) { "STDIN.sync = true; STDOUT.sync = true; puts gets" } 767 | let(:options) { { input: } } 768 | 769 | it "should copy the input to the child's stdin" do 770 | is_expected.to eql("hello#{LINE_ENDING}") 771 | end 772 | end 773 | 774 | context "when running different types of command" do 775 | let(:script) { open_file.tap(&write_file).tap(&:close).tap(&make_executable) } 776 | let(:file_name) { "#{dir}/Setup Script.cmd" } 777 | let(:script_name) { "\"#{script.path}\"" } 778 | 779 | let(:open_file) { File.open(file_name, "w") } 780 | let(:write_file) { lambda { |f| f.write(script_content) } } 781 | let(:make_executable) { lambda { |f| File.chmod(0755, f.path) } } 782 | 783 | context "with spaces in the path" do 784 | subject { chomped_stdout } 785 | let(:cmd) { script_name } 786 | 787 | context "when running under Unix", :unix_only do 788 | let(:script_content) { "echo blah" } 789 | 790 | it "should execute" do 791 | is_expected.to eql("blah") 792 | end 793 | end 794 | 795 | context "when running under Windows", :windows_only do 796 | let(:cmd) { "#{script_name} #{argument}" } 797 | let(:script_content) { "@echo %1" } 798 | let(:argument) { rand(10000).to_s } 799 | 800 | it "should execute" do 801 | is_expected.to eql(argument) 802 | end 803 | 804 | context "with multiple quotes in the command and args" do 805 | context "when using a batch file" do 806 | let(:argument) { "\"Random #{rand(10000)}\"" } 807 | 808 | it "should execute" do 809 | is_expected.to eql(argument) 810 | end 811 | end 812 | 813 | context "when not using a batch file" do 814 | let(:cmd) { "#{executable_file_name} -command #{script_content}" } 815 | let(:executable_file_name) { "\"#{dir}/Powershell Parser.exe\"".tap(&make_executable!) } 816 | let(:make_executable!) { lambda { |filename| Mixlib::ShellOut.new("copy \"c:\\windows\\system32\\WindowsPowerShell\\v1.0\\powershell.exe\" #{filename}").run_command } } 817 | let(:script_content) { "Write-Host \"#{expected_output}\"" } 818 | let(:expected_output) { "Random #{rand(10000)}" } 819 | 820 | it "should execute" do 821 | is_expected.to eql(expected_output) 822 | end 823 | end 824 | end 825 | end 826 | end 827 | 828 | context "with lots of long arguments" do 829 | subject { chomped_stdout } 830 | 831 | # This number was chosen because it seems to be an actual maximum 832 | # in Windows--somewhere around 6-7K of command line 833 | let(:echotext) { 10000.upto(11340).map(&:to_s).join(" ") } 834 | let(:cmd) { "echo #{echotext}" } 835 | 836 | it "should execute" do 837 | is_expected.to eql(echotext) 838 | end 839 | end 840 | 841 | context "with special characters" do 842 | subject { stdout } 843 | 844 | let(:special_characters) { "<>&|&&||;" } 845 | let(:ruby_code) { "print \"#{special_characters}\"" } 846 | 847 | it "should execute" do 848 | is_expected.to eql(special_characters) 849 | end 850 | end 851 | 852 | context "with backslashes" do 853 | subject { stdout } 854 | let(:backslashes) { %q{\\"\\\\} } 855 | let(:cmd) { ruby_eval.call("print \"#{backslashes}\"") } 856 | 857 | it "should execute" do 858 | is_expected.to eql("\"\\") 859 | end 860 | end 861 | 862 | context "with pipes" do 863 | let(:input_script) { "STDOUT.sync = true; STDERR.sync = true; print true; STDERR.print false" } 864 | let(:output_script) { "print STDIN.read.length" } 865 | let(:cmd) { ruby_eval.call(input_script) + " | " + ruby_eval.call(output_script) } 866 | 867 | it "should execute" do 868 | expect(stdout).to eql("4") 869 | end 870 | 871 | it "should handle stderr" do 872 | expect(stderr).to eql("false") 873 | end 874 | end 875 | 876 | context "with stdout and stderr file pipes" do 877 | let(:code) { "STDOUT.sync = true; STDERR.sync = true; print true; STDERR.print false" } 878 | let(:cmd) { ruby_eval.call(code) + " > #{dump_file}" } 879 | 880 | it "should execute" do 881 | expect(stdout).to eql("") 882 | end 883 | 884 | it "should handle stderr" do 885 | expect(stderr).to eql("false") 886 | end 887 | 888 | it "should write to file pipe" do 889 | expect(dump_file_content).to eql("true") 890 | end 891 | end 892 | 893 | context "with stdin file pipe" do 894 | let(:code) { "STDIN.sync = true; STDOUT.sync = true; STDERR.sync = true; print gets; STDERR.print false" } 895 | let(:cmd) { ruby_eval.call(code) + " < #{dump_file_path}" } 896 | let(:file_content) { "Random content #{rand(100000)}" } 897 | 898 | let(:dump_file_path) { dump_file.path } 899 | let(:dump_file) { open_file.tap(&write_file).tap(&:close) } 900 | let(:file_name) { "#{dir}/input" } 901 | 902 | let(:open_file) { File.open(file_name, "w") } 903 | let(:write_file) { lambda { |f| f.write(file_content) } } 904 | 905 | it "should execute" do 906 | expect(stdout).to eql(file_content) 907 | end 908 | 909 | it "should handle stderr" do 910 | expect(stderr).to eql("false") 911 | end 912 | end 913 | 914 | context "with stdout and stderr file pipes" do 915 | let(:code) { "STDOUT.sync = true; STDERR.sync = true; print true; STDERR.print false" } 916 | let(:cmd) { ruby_eval.call(code) + " > #{dump_file} 2>&1" } 917 | 918 | it "should execute" do 919 | expect(stdout).to eql("") 920 | end 921 | 922 | it "should write to file pipe" do 923 | expect(dump_file_content).to eql("truefalse") 924 | end 925 | end 926 | 927 | context "with &&" do 928 | subject { stdout } 929 | let(:cmd) { ruby_eval.call('print "foo"') + " && " + ruby_eval.call('print "bar"') } 930 | 931 | it "should execute" do 932 | is_expected.to eql("foobar") 933 | end 934 | end 935 | 936 | context "with ||" do 937 | let(:cmd) { ruby_eval.call('print "foo"; exit 1') + " || " + ruby_eval.call('print "bar"') } 938 | 939 | it "should execute" do 940 | expect(stdout).to eql("foobar") 941 | end 942 | 943 | it "should exit with code 0" do 944 | expect(exit_status).to eql(0) 945 | end 946 | end 947 | end 948 | 949 | context "when handling process exit codes" do 950 | let(:cmd) { ruby_eval.call("exit #{exit_code}") } 951 | 952 | context "with normal exit status" do 953 | let(:exit_code) { 0 } 954 | 955 | it "should not raise error" do 956 | expect { executed_cmd.error! }.not_to raise_error 957 | end 958 | 959 | it "should set the exit status of the command" do 960 | expect(exit_status).to eql(exit_code) 961 | end 962 | end 963 | 964 | context "with nonzero exit status" do 965 | let(:exit_code) { 2 } 966 | let(:exception_message_format) { Regexp.escape(executed_cmd.format_for_exception) } 967 | 968 | it "should raise ShellCommandFailed" do 969 | expect { executed_cmd.error! }.to raise_error(Mixlib::ShellOut::ShellCommandFailed) 970 | end 971 | 972 | it "includes output with exceptions from #error!" do 973 | 974 | executed_cmd.error! 975 | rescue Mixlib::ShellOut::ShellCommandFailed => e 976 | expect(e.message).to match(exception_message_format) 977 | 978 | end 979 | 980 | it "should set the exit status of the command" do 981 | expect(exit_status).to eql(exit_code) 982 | end 983 | end 984 | 985 | context "with valid exit codes" do 986 | let(:cmd) { ruby_eval.call("exit #{exit_code}" ) } 987 | let(:options) { { returns: valid_exit_codes } } 988 | 989 | context "when exiting with valid code" do 990 | let(:valid_exit_codes) { 42 } 991 | let(:exit_code) { 42 } 992 | 993 | it "should not raise error" do 994 | expect { executed_cmd.error! }.not_to raise_error 995 | end 996 | 997 | it "should set the exit status of the command" do 998 | expect(exit_status).to eql(exit_code) 999 | end 1000 | end 1001 | 1002 | context "when exiting with invalid code" do 1003 | let(:valid_exit_codes) { [ 0, 1, 42 ] } 1004 | let(:exit_code) { 2 } 1005 | 1006 | it "should raise ShellCommandFailed" do 1007 | expect { executed_cmd.error! }.to raise_error(Mixlib::ShellOut::ShellCommandFailed) 1008 | end 1009 | 1010 | it "should set the exit status of the command" do 1011 | expect(exit_status).to eql(exit_code) 1012 | end 1013 | 1014 | context "with input data" do 1015 | let(:options) { { returns: valid_exit_codes, input: } } 1016 | let(:input) { "Random data #{rand(1000000)}" } 1017 | 1018 | it "should raise ShellCommandFailed" do 1019 | expect { executed_cmd.error! }.to raise_error(Mixlib::ShellOut::ShellCommandFailed) 1020 | end 1021 | 1022 | it "should set the exit status of the command" do 1023 | expect(exit_status).to eql(exit_code) 1024 | end 1025 | end 1026 | end 1027 | 1028 | context "when exiting with invalid code 0" do 1029 | let(:valid_exit_codes) { 42 } 1030 | let(:exit_code) { 0 } 1031 | 1032 | it "should raise ShellCommandFailed" do 1033 | expect { executed_cmd.error! }.to raise_error(Mixlib::ShellOut::ShellCommandFailed) 1034 | end 1035 | 1036 | it "should set the exit status of the command" do 1037 | expect(exit_status).to eql(exit_code) 1038 | end 1039 | end 1040 | end 1041 | 1042 | describe "#invalid!" do 1043 | let(:exit_code) { 0 } 1044 | 1045 | it "should raise ShellCommandFailed" do 1046 | expect { executed_cmd.invalid!("I expected this to exit 42, not 0") }.to raise_error(Mixlib::ShellOut::ShellCommandFailed) 1047 | end 1048 | end 1049 | 1050 | describe "#error?" do 1051 | context "when exiting with invalid code" do 1052 | let(:exit_code) { 2 } 1053 | 1054 | it "should return true" do 1055 | expect(executed_cmd.error?).to be_truthy 1056 | end 1057 | end 1058 | 1059 | context "when exiting with valid code" do 1060 | let(:exit_code) { 0 } 1061 | 1062 | it "should return false" do 1063 | expect(executed_cmd.error?).to be_falsey 1064 | end 1065 | end 1066 | end 1067 | end 1068 | 1069 | context "when handling the subprocess" do 1070 | context "with STDOUT and STDERR" do 1071 | let(:ruby_code) { "STDERR.puts :hello; STDOUT.puts :world" } 1072 | 1073 | # We could separate this into two examples, but we want to make 1074 | # sure that stderr and stdout gets collected without stepping 1075 | # on each other. 1076 | it "should collect all of STDOUT and STDERR" do 1077 | expect(stderr).to eql("hello#{LINE_ENDING}") 1078 | expect(stdout).to eql("world#{LINE_ENDING}") 1079 | end 1080 | end 1081 | 1082 | context "with forking subprocess that does not close stdout and stderr" do 1083 | let(:ruby_code) { "exit if fork; 10.times { sleep 1 }" } 1084 | 1085 | it "should not hang" do 1086 | expect do 1087 | Timeout.timeout(2) do 1088 | executed_cmd 1089 | end 1090 | end.not_to raise_error 1091 | end 1092 | end 1093 | 1094 | context "when running a command that doesn't exist", :unix_only do 1095 | 1096 | let(:cmd) { "/bin/this-is-not-a-real-command" } 1097 | 1098 | def shell_out_cmd 1099 | Mixlib::ShellOut.new(cmd) 1100 | end 1101 | 1102 | it "reaps zombie processes after exec fails [OHAI-455]" do 1103 | # NOTE: depending on ulimit settings, GC, etc., before the OHAI-455 patch, 1104 | # ohai could also exhaust the available file descriptors when creating this 1105 | # many zombie processes. A regression _could_ cause Errno::EMFILE but this 1106 | # probably won't be consistent on different environments. 1107 | created_procs = 0 1108 | 100.times do 1109 | 1110 | shell_out_cmd.run_command 1111 | rescue Errno::ENOENT 1112 | created_procs += 1 1113 | 1114 | end 1115 | expect(created_procs).to eq(100) 1116 | reaped_procs = 0 1117 | begin 1118 | loop { Process.wait(-1); reaped_procs += 1 } 1119 | rescue Errno::ECHILD 1120 | end 1121 | expect(reaped_procs).to eq(0) 1122 | end 1123 | end 1124 | 1125 | context "with open files for parent process" do 1126 | before do 1127 | @test_file = Tempfile.new("fd_test") 1128 | @test_file.write("hello") 1129 | @test_file.flush 1130 | end 1131 | 1132 | after do 1133 | @test_file&.close 1134 | end 1135 | 1136 | let(:ruby_code) { "fd = File.for_fd(#{@test_file.to_i}) rescue nil; if fd; fd.seek(0); puts fd.read(5); end" } 1137 | 1138 | it "should not see file descriptors of the parent" do 1139 | # The reason this test goes through the effor of writing out 1140 | # a file and checking the contents along side the presence of 1141 | # a file descriptor is because on Windows, we're seeing that 1142 | # a there is a file descriptor present, but it's not the same 1143 | # file. That means that if we just check for the presence of 1144 | # a file descriptor, the test would fail as that slot would 1145 | # have something. 1146 | # 1147 | # See https://github.com/chef/mixlib-shellout/pull/103 1148 | # 1149 | expect(stdout.chomp).not_to eql("hello") 1150 | end 1151 | end 1152 | 1153 | context "when the child process dies immediately" do 1154 | let(:cmd) { [ "exit" ] } 1155 | 1156 | it "handles ESRCH from getpgid of a zombie", :unix_only do 1157 | allow(Process).to receive(:setsid) { exit!(4) } 1158 | 1159 | # we used to have race conditions if the child exited and zombied 1160 | # quickly which would cause an exception. we no longer call getpgrp() 1161 | # after setsid()/setpgrp() though so this race condition should no 1162 | # longer exist. still test 5 times for it though. 1163 | 5.times do 1164 | s = Mixlib::ShellOut.new(cmd) 1165 | s.run_command # should not raise Errno::ESRCH (or anything else) 1166 | end 1167 | 1168 | end 1169 | 1170 | end 1171 | 1172 | context "with subprocess that takes longer than timeout" do 1173 | let(:options) { { timeout: 1 } } 1174 | 1175 | context "on windows", :windows_only do 1176 | let(:cmd) do 1177 | 'cmd /c powershell -c "sleep 10"' 1178 | end 1179 | 1180 | it "should raise CommandTimeout" do 1181 | Timeout.timeout(5) do 1182 | expect { executed_cmd }.to raise_error(Mixlib::ShellOut::CommandTimeout) 1183 | end 1184 | end 1185 | 1186 | context "and child processes should be killed" do 1187 | it "kills the child processes" do 1188 | expect(shell_cmd).to receive(:kill_process) do |instance| 1189 | expect(instance.wmi_ole_object.Name).to match(/powershell.exe/) 1190 | Process.kill(:KILL, instance.wmi_ole_object.processid) 1191 | end 1192 | expect { executed_cmd }.to raise_error(Mixlib::ShellOut::CommandTimeout) 1193 | end 1194 | end 1195 | end 1196 | 1197 | context "on unix", :unix_only do 1198 | def ruby_wo_shell(code) 1199 | parts = %w{ruby} 1200 | parts << "-e" 1201 | parts << code 1202 | end 1203 | 1204 | let(:cmd) do 1205 | ruby_wo_shell(<<-CODE) 1206 | STDOUT.sync = true 1207 | trap(:TERM) { puts "got term"; exit!(123) } 1208 | sleep 10 1209 | CODE 1210 | end 1211 | 1212 | it "should raise CommandTimeout" do 1213 | expect { executed_cmd }.to raise_error(Mixlib::ShellOut::CommandTimeout) 1214 | end 1215 | 1216 | it "should ask the process nicely to exit" do 1217 | # note: let blocks don't correctly memoize if an exception is raised, 1218 | # so can't use executed_cmd 1219 | expect { shell_cmd.run_command }.to raise_error(Mixlib::ShellOut::CommandTimeout) 1220 | expect(shell_cmd.stdout).to include("got term") 1221 | expect(shell_cmd.exitstatus).to eq(123) 1222 | end 1223 | 1224 | context "and the child is unresponsive" do 1225 | let(:cmd) do 1226 | ruby_wo_shell(<<-CODE) 1227 | STDOUT.sync = true 1228 | trap(:TERM) { puts "nanana cant hear you" } 1229 | sleep 10 1230 | CODE 1231 | end 1232 | 1233 | it "should KILL the wayward child" do 1234 | # note: let blocks don't correctly memoize if an exception is raised, 1235 | # so can't use executed_cmd 1236 | expect { shell_cmd.run_command }.to raise_error(Mixlib::ShellOut::CommandTimeout) 1237 | expect(shell_cmd.stdout).to include("nanana cant hear you") 1238 | expect(shell_cmd.status.termsig).to eq(9) 1239 | end 1240 | 1241 | context "and a logger is configured" do 1242 | let(:log_output) { StringIO.new } 1243 | let(:logger) { Logger.new(log_output) } 1244 | let(:options) { { timeout: 1, logger: } } 1245 | 1246 | it "should log messages about killing the child process" do 1247 | # note: let blocks don't correctly memoize if an exception is raised, 1248 | # so can't use executed_cmd 1249 | expect { shell_cmd.run_command }.to raise_error(Mixlib::ShellOut::CommandTimeout) 1250 | expect(shell_cmd.stdout).to include("nanana cant hear you") 1251 | expect(shell_cmd.status.termsig).to eq(9) 1252 | 1253 | expect(log_output.string).to include("Command exceeded allowed execution time, sending TERM") 1254 | expect(log_output.string).to include("Command exceeded allowed execution time, sending KILL") 1255 | end 1256 | 1257 | end 1258 | end 1259 | 1260 | context "and the child process forks grandchildren" do 1261 | let(:cmd) do 1262 | ruby_wo_shell(<<-CODE) 1263 | STDOUT.sync = true 1264 | trap(:TERM) { print "got term in child\n"; exit!(123) } 1265 | fork do 1266 | trap(:TERM) { print "got term in grandchild\n"; exit!(142) } 1267 | sleep 10 1268 | end 1269 | sleep 10 1270 | CODE 1271 | end 1272 | 1273 | it "should TERM the wayward child and grandchild" do 1274 | # note: let blocks don't correctly memoize if an exception is raised, 1275 | # so can't use executed_cmd 1276 | expect { shell_cmd.run_command }.to raise_error(Mixlib::ShellOut::CommandTimeout) 1277 | expect(shell_cmd.stdout).to include("got term in child") 1278 | expect(shell_cmd.stdout).to include("got term in grandchild") 1279 | end 1280 | 1281 | end 1282 | context "and the child process forks grandchildren that don't respond to TERM" do 1283 | let(:cmd) do 1284 | ruby_wo_shell(<<-CODE) 1285 | STDOUT.sync = true 1286 | 1287 | trap(:TERM) { print "got term in child\n"; exit!(123) } 1288 | fork do 1289 | trap(:TERM) { print "got term in grandchild\n" } 1290 | sleep 10 1291 | end 1292 | sleep 10 1293 | CODE 1294 | end 1295 | 1296 | it "should TERM the wayward child and grandchild, then KILL whoever is left" do 1297 | # note: let blocks don't correctly memoize if an exception is raised, 1298 | # so can't use executed_cmd 1299 | expect { shell_cmd.run_command }.to raise_error(Mixlib::ShellOut::CommandTimeout) 1300 | 1301 | begin 1302 | 1303 | # A little janky. We get the process group id out of the command 1304 | # object, then try to kill a process in it to make sure none 1305 | # exists. Trusting the system under test like this isn't great but 1306 | # it's difficult to test otherwise. 1307 | child_pgid = shell_cmd.send(:child_pgid) 1308 | initial_process_listing = `ps -j` 1309 | 1310 | expect(shell_cmd.stdout).to include("got term in child") 1311 | expect(shell_cmd.stdout).to include("got term in grandchild") 1312 | 1313 | kill_return_val = Process.kill(:INT, child_pgid) # should raise ESRCH 1314 | # AIX - kill returns code > 0 for error, where as other platforms return -1. Ruby code signal.c treats < 0 as error and raises exception and hence fails on AIX. So we check the return code for assertions since ruby wont raise an error here. 1315 | 1316 | if kill_return_val == 0 1317 | # Debug the failure: 1318 | puts "child pgid=#{child_pgid.inspect}" 1319 | Process.wait 1320 | puts "collected process: #{$?.inspect}" 1321 | puts "initial process listing:\n#{initial_process_listing}" 1322 | puts "current process listing:" 1323 | puts `ps -j` 1324 | raise "Failed to kill all expected processes" 1325 | end 1326 | rescue Errno::ESRCH 1327 | # this is what we want 1328 | end 1329 | end 1330 | 1331 | end 1332 | 1333 | end 1334 | end 1335 | 1336 | context "with subprocess that exceeds buffersize" do 1337 | let(:ruby_code) { 'print("X" * 16 * 1024); print("." * 1024)' } 1338 | 1339 | it "should still reads all of the output" do 1340 | expect(stdout).to match(/X{16384}\.{1024}/) 1341 | end 1342 | end 1343 | 1344 | context "with subprocess that returns nothing" do 1345 | let(:ruby_code) { "exit 0" } 1346 | 1347 | it "should return an empty string for stdout" do 1348 | expect(stdout).to eql("") 1349 | end 1350 | 1351 | it "should return an empty string for stderr" do 1352 | expect(stderr).to eql("") 1353 | end 1354 | end 1355 | 1356 | context "with subprocess that closes stdin and continues writing to stdout" do 1357 | let(:ruby_code) { "STDIN.close; sleep 0.5; STDOUT.puts :win" } 1358 | let(:options) { { input: "Random data #{rand(100000)}" } } 1359 | 1360 | it "should not hang or lose output" do 1361 | expect(stdout).to eql("win#{LINE_ENDING}") 1362 | end 1363 | end 1364 | 1365 | context "with subprocess that closes stdout and continues writing to stderr" do 1366 | let(:ruby_code) { "STDOUT.close; sleep 0.5; STDERR.puts :win" } 1367 | 1368 | it "should not hang or lose output" do 1369 | expect(stderr).to eql("win#{LINE_ENDING}") 1370 | end 1371 | end 1372 | 1373 | context "with subprocess that closes stderr and continues writing to stdout" do 1374 | let(:ruby_code) { "STDERR.close; sleep 0.5; STDOUT.puts :win" } 1375 | 1376 | it "should not hang or lose output" do 1377 | expect(stdout).to eql("win#{LINE_ENDING}") 1378 | end 1379 | end 1380 | 1381 | # Regression test: 1382 | # 1383 | # We need to ensure that stderr is removed from the list of file 1384 | # descriptors that we attempt to select() on in the case that: 1385 | # 1386 | # a) STDOUT closes first 1387 | # b) STDERR closes 1388 | # c) The program does not exit for some time after (b) occurs. 1389 | # 1390 | # Otherwise, we will attempt to read from the closed STDOUT pipe over and 1391 | # over again and generate lots of garbage, which will not be collected 1392 | # since we have to turn GC off to avoid segv. 1393 | context "with subprocess that closes STDOUT before closing STDERR" do 1394 | let(:ruby_code) { %q{STDOUT.puts "F" * 4096; STDOUT.close; sleep 0.1; STDERR.puts "foo"; STDERR.close; sleep 0.1; exit} } 1395 | let(:unclosed_pipes) { executed_cmd.send(:open_pipes) } 1396 | 1397 | it "should not hang" do 1398 | expect(stdout).not_to be_empty 1399 | end 1400 | 1401 | it "should close all pipes", :unix_only do 1402 | expect(unclosed_pipes).to be_empty 1403 | end 1404 | end 1405 | 1406 | context "with subprocess reading lots of data from stdin" do 1407 | subject { stdout.to_i } 1408 | let(:ruby_code) { "STDOUT.print gets.size" } 1409 | let(:options) { { input: } } 1410 | let(:input) { "f" * 20_000 } 1411 | let(:input_size) { input.size } 1412 | 1413 | it "should not hang" do 1414 | is_expected.to eql(input_size) 1415 | end 1416 | end 1417 | 1418 | context "with subprocess writing lots of data to both stdout and stderr" do 1419 | let(:expected_output_with) { lambda { |chr| (chr * 20_000) + LINE_ENDING.to_s + (chr * 20_000) + LINE_ENDING.to_s } } 1420 | 1421 | context "when writing to STDOUT first" do 1422 | let(:ruby_code) { %q{puts "f" * 20_000; STDERR.puts "u" * 20_000; puts "f" * 20_000; STDERR.puts "u" * 20_000} } 1423 | 1424 | it "should not deadlock" do 1425 | expect(stdout).to eql(expected_output_with.call("f")) 1426 | expect(stderr).to eql(expected_output_with.call("u")) 1427 | end 1428 | end 1429 | 1430 | context "when writing to STDERR first" do 1431 | let(:ruby_code) { %q{STDERR.puts "u" * 20_000; puts "f" * 20_000; STDERR.puts "u" * 20_000; puts "f" * 20_000} } 1432 | 1433 | it "should not deadlock" do 1434 | expect(stdout).to eql(expected_output_with.call("f")) 1435 | expect(stderr).to eql(expected_output_with.call("u")) 1436 | end 1437 | end 1438 | end 1439 | 1440 | context "with subprocess piping lots of data through stdin, stdout, and stderr" do 1441 | let(:multiplier) { 20 } 1442 | let(:expected_output_with) { lambda { |chr| (chr * multiplier) + (chr * multiplier) } } 1443 | 1444 | # Use regex to work across Ruby versions 1445 | let(:ruby_code) { "STDOUT.sync = STDERR.sync = true; while(input = gets) do ( input =~ /^f/ ? STDOUT : STDERR ).print input.chomp; end" } 1446 | 1447 | let(:options) { { input: } } 1448 | 1449 | context "when writing to STDOUT first" do 1450 | let(:input) { [ "f" * multiplier, "u" * multiplier, "f" * multiplier, "u" * multiplier ].join(LINE_ENDING) } 1451 | 1452 | it "should not deadlock" do 1453 | expect(stdout).to eql(expected_output_with.call("f")) 1454 | expect(stderr).to eql(expected_output_with.call("u")) 1455 | end 1456 | end 1457 | 1458 | context "when writing to STDERR first" do 1459 | let(:input) { [ "u" * multiplier, "f" * multiplier, "u" * multiplier, "f" * multiplier ].join(LINE_ENDING) } 1460 | 1461 | it "should not deadlock" do 1462 | expect(stdout).to eql(expected_output_with.call("f")) 1463 | expect(stderr).to eql(expected_output_with.call("u")) 1464 | end 1465 | end 1466 | end 1467 | 1468 | context "when subprocess closes prematurely", :unix_only do 1469 | context "with input data" do 1470 | let(:ruby_code) { "bad_ruby { [ } ]" } 1471 | let(:options) { { input: } } 1472 | # https://github.com/chef/mixlib-shellout/issues/204 1473 | let(:repeats) { 20_000 * (Etc.sysconf(Etc::SC_PAGESIZE) / 4096) } 1474 | let(:input) { [ "f" * repeats, "u" * repeats, "f" * repeats, "u" * repeats ].join(LINE_ENDING) } 1475 | 1476 | # Should the exception be handled? 1477 | it "should raise error" do 1478 | expect { executed_cmd }.to raise_error(Errno::EPIPE) 1479 | end 1480 | end 1481 | end 1482 | 1483 | context "when subprocess writes, pauses, then continues writing" do 1484 | subject { stdout } 1485 | let(:ruby_code) { %q{puts "before"; sleep 0.5; puts "after"} } 1486 | 1487 | it "should not hang or lose output" do 1488 | is_expected.to eql("before#{LINE_ENDING}after#{LINE_ENDING}") 1489 | end 1490 | end 1491 | 1492 | context "when subprocess pauses before writing" do 1493 | subject { stdout } 1494 | let(:ruby_code) { 'sleep 0.5; puts "missed_the_bus"' } 1495 | 1496 | it "should not hang or lose output" do 1497 | is_expected.to eql("missed_the_bus#{LINE_ENDING}") 1498 | end 1499 | end 1500 | 1501 | context "when subprocess pauses before reading from stdin" do 1502 | subject { stdout.to_i } 1503 | let(:ruby_code) { "sleep 0.5; print gets.size " } 1504 | let(:input) { "c" * 1024 } 1505 | let(:input_size) { input.size } 1506 | let(:options) { { input: } } 1507 | 1508 | it "should not hang or lose output" do 1509 | is_expected.to eql(input_size) 1510 | end 1511 | end 1512 | 1513 | context "when execution fails" do 1514 | let(:cmd) { "fuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuu" } 1515 | 1516 | context "when running under Unix", :unix_only do 1517 | it "should recover the error message" do 1518 | expect { executed_cmd }.to raise_error(Errno::ENOENT) 1519 | end 1520 | 1521 | context "with input" do 1522 | let(:options) { { input: } } 1523 | let(:input) { "Random input #{rand(1000000)}" } 1524 | 1525 | it "should recover the error message" do 1526 | expect { executed_cmd }.to raise_error(Errno::ENOENT) 1527 | end 1528 | end 1529 | end 1530 | 1531 | skip "when running under Windows", :windows_only 1532 | end 1533 | 1534 | context "without input data" do 1535 | context "with subprocess that expects stdin" do 1536 | let(:ruby_code) { %q{print STDIN.eof?.to_s} } 1537 | 1538 | # If we don't have anything to send to the subprocess, we need to close 1539 | # stdin so that the subprocess won't wait for input. 1540 | it "should close stdin" do 1541 | expect(stdout).to eql("true") 1542 | end 1543 | end 1544 | end 1545 | end 1546 | 1547 | describe "#format_for_exception" do 1548 | let(:ruby_code) { %q{STDERR.puts "msg_in_stderr"; puts "msg_in_stdout"} } 1549 | let(:exception_output) { executed_cmd.format_for_exception.split("\n") } 1550 | let(:expected_output) do 1551 | [ 1552 | "---- Begin output of #{cmd} ----", 1553 | %q{STDOUT: msg_in_stdout}, 1554 | %q{STDERR: msg_in_stderr}, 1555 | "---- End output of #{cmd} ----", 1556 | "Ran #{cmd} returned 0", 1557 | ] 1558 | end 1559 | 1560 | it "should format exception messages" do 1561 | exception_output.each_with_index do |output_line, i| 1562 | expect(output_line).to eql(expected_output[i]) 1563 | end 1564 | end 1565 | end 1566 | end 1567 | 1568 | context "when running under *nix", :requires_root, :unix_only do 1569 | let(:cmd) { "whoami" } 1570 | let(:running_user) { shell_cmd.run_command.stdout.chomp } 1571 | 1572 | context "when no user is set" do 1573 | it "should run as current user" do 1574 | expect(running_user).to eql(Etc.getpwuid.name) 1575 | end 1576 | end 1577 | 1578 | context "when user is specified" do 1579 | let(:user) { "nobody" } 1580 | 1581 | let(:options) { { user: } } 1582 | 1583 | it "should run as specified user" do 1584 | expect(running_user).to eql(user.to_s) 1585 | end 1586 | end 1587 | end 1588 | 1589 | context "when running on a cgroup", :linux_only do 1590 | let(:cmd) { "cat /proc/self/cgroup | cut -c 4-" } 1591 | let(:options) { { cgroup: } } 1592 | let(:cgroupv2_supported) { File.read("/proc/mounts").match(%r{^cgroup2 /sys/fs/cgroup}) } 1593 | 1594 | context "when cgroup exists" do 1595 | let(:cgroup) { "#{File.read("/proc/self/cgroup")[%r{(/.*)$}, 1]}" } 1596 | let(:running_cgroup) { shell_cmd.run_command.stdout.chomp } 1597 | it "should run the process under that cgroup" do 1598 | if cgroupv2_supported 1599 | expect(running_cgroup).to eql(cgroup.to_s) 1600 | end 1601 | end 1602 | end 1603 | 1604 | context "when cgroup does not exist" do 1605 | let(:cgroup) { "#{File.read("/proc/self/cgroup")[%r{(/.*)/[^/]+$}, 1]}/test" } 1606 | let(:running_cgroup) { shell_cmd.run_command.stdout.chomp } 1607 | it "should create the cgroup and run the process under it" do 1608 | if cgroupv2_supported 1609 | expect(running_cgroup).to eql(cgroup.to_s) 1610 | Dir.rmdir("/sys/fs/cgroup/#{cgroup}") 1611 | end 1612 | end 1613 | end 1614 | end 1615 | end 1616 | -------------------------------------------------------------------------------- /spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | require "mixlib/shellout" 2 | 3 | require "tmpdir" 4 | require "tempfile" 5 | require "timeout" 6 | 7 | # Load everything from spec/support 8 | Dir["spec/support/**/*.rb"].each { |f| require File.expand_path(f) } 9 | 10 | RSpec.configure do |config| 11 | config.mock_with :rspec 12 | config.filter_run focus: true 13 | config.filter_run_excluding external: true 14 | 15 | # Add jruby filters here 16 | config.filter_run_excluding windows_only: true unless windows? 17 | config.filter_run_excluding unix_only: true unless unix? 18 | config.filter_run_excluding linux_only: true unless linux? 19 | config.filter_run_excluding requires_root: true unless root? 20 | config.filter_run_excluding ruby: DependencyProc.with(RUBY_VERSION) 21 | 22 | config.run_all_when_everything_filtered = true 23 | 24 | config.warnings = true 25 | 26 | config.expect_with :rspec do |c| 27 | c.syntax = :expect 28 | end 29 | end 30 | -------------------------------------------------------------------------------- /spec/support/dependency_helper.rb: -------------------------------------------------------------------------------- 1 | class DependencyProc < Proc 2 | attr_accessor :present 3 | 4 | def self.with(present) 5 | provided = Gem::Version.new(present.dup) 6 | new do |required| 7 | !Gem::Requirement.new(required).satisfied_by?(provided) 8 | end.tap { |l| l.present = present } 9 | end 10 | 11 | def inspect 12 | "\"#{present}\"" 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /spec/support/platform_helpers.rb: -------------------------------------------------------------------------------- 1 | def windows? 2 | !!(RUBY_PLATFORM =~ /mswin|mingw|windows/) 3 | end 4 | 5 | def unix? 6 | !windows? 7 | end 8 | 9 | def linux? 10 | !!(RUBY_PLATFORM =~ /linux/) 11 | end 12 | 13 | if windows? 14 | LINE_ENDING = "\r\n".freeze 15 | ECHO_LC_ALL = "echo %LC_ALL%".freeze 16 | else 17 | LINE_ENDING = "\n".freeze 18 | ECHO_LC_ALL = "echo $LC_ALL".freeze 19 | end 20 | 21 | def root? 22 | return false if windows? 23 | 24 | Process.euid == 0 25 | end 26 | --------------------------------------------------------------------------------