diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9fac88d..521bd7d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,7 +2,7 @@ name: CI on: push: - branches: [main, develop, 'release/**'] + branches: [main, develop, release/**] pull_request: branches: [main, develop] @@ -74,11 +74,11 @@ jobs: bundle audit --update || true continue-on-error: true - publish: + build: runs-on: ubuntu-latest needs: [test, security] - if: github.event_name == 'push' && (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/develop' || startsWith(github.ref, 'refs/heads/release/')) - + if: github.event_name == 'push' + steps: - uses: actions/checkout@v4 @@ -91,25 +91,45 @@ jobs: - name: Modify version for develop branch if: github.ref == 'refs/heads/develop' run: | - sed -i "s/VERSION = '\([^']*\)'/VERSION = '\1.dev'/" lib/sudo/constants.rb + SHORT_SHA=$(git rev-parse --short HEAD) + sed -i "s/VERSION = '\([^']*\)'/VERSION = '\1.dev.${SHORT_SHA}'/" lib/sudo/constants.rb + echo "VERSION_SUFFIX=.dev.${SHORT_SHA}" >> $GITHUB_ENV - name: Modify version for release branch if: startsWith(github.ref, 'refs/heads/release/') run: | - sed -i "s/VERSION = '\([^']*\)'/VERSION = '\1.rc'/" lib/sudo/constants.rb + SHORT_SHA=$(git rev-parse --short HEAD) + sed -i "s/VERSION = '\([^']*\)'/VERSION = '\1.rc.${SHORT_SHA}'/" lib/sudo/constants.rb + echo "VERSION_SUFFIX=.rc.${SHORT_SHA}" >> $GITHUB_ENV + + - name: Set version suffix for main + if: github.ref == 'refs/heads/main' + run: echo "VERSION_SUFFIX=" >> $GITHUB_ENV - name: Build gem run: gem build sudo.gemspec - - name: Publish to GitHub Packages + - name: Get gem info + id: gem_info + run: | + GEM_FILE=$(ls *.gem) + GEM_VERSION=$(echo $GEM_FILE | sed 's/sudo-\(.*\)\.gem/\1/') + echo "gem_file=$GEM_FILE" >> $GITHUB_OUTPUT + echo "gem_version=$GEM_VERSION" >> $GITHUB_OUTPUT + + - name: Store gem artifact + uses: actions/upload-artifact@v4 + with: + name: gem-${{ steps.gem_info.outputs.gem_version }} + path: "*.gem" + retention-days: 30 + + - name: Create build summary run: | - mkdir -p ~/.gem - cat << EOF > ~/.gem/credentials - --- - :github: Bearer ${{ secrets.GITHUB_TOKEN }} - EOF - chmod 600 ~/.gem/credentials - # Temporarily remove allowed_push_host restriction for GitHub Packages - sed -i "s/spec.metadata\['allowed_push_host'\].*$//" sudo.gemspec - gem build sudo.gemspec - gem push --key github --host https://rubygems.pkg.github.com/TwilightCoders *.gem + echo "## Gem Built Successfully 💎" >> $GITHUB_STEP_SUMMARY + echo "- **Version**: ${{ steps.gem_info.outputs.gem_version }}" >> $GITHUB_STEP_SUMMARY + echo "- **File**: ${{ steps.gem_info.outputs.gem_file }}" >> $GITHUB_STEP_SUMMARY + echo "- **Branch**: ${{ github.ref_name }}" >> $GITHUB_STEP_SUMMARY + echo "- **Commit**: ${{ github.sha }}" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "🚀 **Ready to publish!** Use the 'Manual Release' workflow to publish this gem." >> $GITHUB_STEP_SUMMARY diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..e8421f0 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,150 @@ +name: Manual Release + +on: + workflow_dispatch: + inputs: + target: + description: 'Release target' + required: true + default: 'github' + type: choice + options: + - github + - rubygems + - both + run_id: + description: 'CI Run ID to use (optional - leave empty to build from current commit)' + required: false + type: string + version_override: + description: 'Version override (optional - leave empty to use VERSION constant)' + required: false + type: string + confirm: + description: 'Type "confirm" to proceed with release' + required: true + type: string + +permissions: + actions: write + contents: read + id-token: write + packages: write + +jobs: + validate: + runs-on: ubuntu-latest + steps: + - name: Validate confirmation + if: inputs.confirm != 'confirm' + run: | + echo "::error::You must type 'confirm' to proceed with release" + exit 1 + + release: + runs-on: ubuntu-latest + needs: validate + + steps: + - uses: actions/checkout@v4 + + - name: Set up Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: "3.3" + bundler-cache: true + + - name: Download gem artifact + if: inputs.run_id != '' + uses: actions/download-artifact@v4 + with: + run-id: ${{ inputs.run_id }} + pattern: gem-* + merge-multiple: true + + - name: Build gem from current commit + if: inputs.run_id == '' + run: | + # Override version if specified + if [ "${{ inputs.version_override }}" != "" ]; then + sed -i "s/VERSION = '\([^']*\)'/VERSION = '${{ inputs.version_override }}'/" lib/sudo/constants.rb + echo "Version overridden to: ${{ inputs.version_override }}" + fi + + # Run tests first + bundle exec rspec + + # Build gem + gem build sudo.gemspec + + - name: Show gem info and get publish details + id: gem_details + run: | + echo "Available gems:" + ls -la *.gem + echo "" + + # Get the gem file (assuming single gem) + GEM_FILE=$(ls *.gem | head -1) + GEM_VERSION=$(echo $GEM_FILE | sed 's/sudo-\(.*\)\.gem/\1/') + + echo "gem_file=$GEM_FILE" >> $GITHUB_OUTPUT + echo "gem_version=$GEM_VERSION" >> $GITHUB_OUTPUT + + echo "## 💎 PUBLISHING CONFIRMATION" + echo "**Gem Name:** sudo" + echo "**Version:** $GEM_VERSION" + echo "**File:** $GEM_FILE" + echo "**Target:** ${{ inputs.target }}" + echo "**Size:** $(ls -lh $GEM_FILE | awk '{print $5}')" + echo "" + echo "Gem contents preview:" + gem contents "$GEM_FILE" | head -10 + echo "... (and $(gem contents "$GEM_FILE" | wc -l) total files)" + + - name: Confirm publication details + run: | + echo "## 🚀 READY TO PUBLISH" >> $GITHUB_STEP_SUMMARY + echo "- **Gem**: sudo" >> $GITHUB_STEP_SUMMARY + echo "- **Version**: ${{ steps.gem_details.outputs.gem_version }}" >> $GITHUB_STEP_SUMMARY + echo "- **File**: ${{ steps.gem_details.outputs.gem_file }}" >> $GITHUB_STEP_SUMMARY + echo "- **Target**: ${{ inputs.target }}" >> $GITHUB_STEP_SUMMARY + echo "- **Commit**: ${{ github.sha }}" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "Publishing in 5 seconds..." >> $GITHUB_STEP_SUMMARY + sleep 5 + + - name: Publish to GitHub Packages + if: inputs.target == 'github' || inputs.target == 'both' + run: | + mkdir -p ~/.gem + cat << EOF > ~/.gem/credentials + --- + :github: Bearer ${{ secrets.GITHUB_TOKEN }} + EOF + chmod 600 ~/.gem/credentials + # Temporarily remove allowed_push_host restriction for GitHub Packages + sed -i "s/spec.metadata\['allowed_push_host'\].*$//" sudo.gemspec + gem build sudo.gemspec + gem push --key github --host https://rubygems.pkg.github.com/TwilightCoders *.gem + + - name: Publish to RubyGems.org + if: inputs.target == 'rubygems' || inputs.target == 'both' + env: + GEM_HOST_API_KEY: ${{ secrets.RUBYGEMS_API_KEY }} + run: | + mkdir -p ~/.gem + cat << EOF > ~/.gem/credentials + --- + :rubygems_api_key: ${{ secrets.RUBYGEMS_API_KEY }} + EOF + chmod 600 ~/.gem/credentials + gem push *.gem + + - name: Create release summary + run: | + echo "## Release Summary" >> $GITHUB_STEP_SUMMARY + echo "- **Target**: ${{ inputs.target }}" >> $GITHUB_STEP_SUMMARY + echo "- **Version**: $(ls *.gem | sed 's/sudo-\(.*\)\.gem/\1/')" >> $GITHUB_STEP_SUMMARY + echo "- **Branch**: ${{ github.ref_name }}" >> $GITHUB_STEP_SUMMARY + echo "- **Commit**: ${{ github.sha }}" >> $GITHUB_STEP_SUMMARY \ No newline at end of file diff --git a/README.md b/README.md index 564bfcf..9795177 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,7 @@ [![CI](https://github.com/TwilightCoders/rubysu/actions/workflows/ci.yml/badge.svg)](https://github.com/TwilightCoders/rubysu/actions/workflows/ci.yml) [![Maintainability](https://qlty.sh/badges/e63e40be-4d72-4519-ad77-d4f94803a7b9/maintainability.svg)](https://qlty.sh/TwilightCoders/rubysu) [![Test Coverage](https://qlty.sh/badges/e63e40be-4d72-4519-ad77-d4f94803a7b9/test_coverage.svg)](https://qlty.sh/TwilightCoders/rubysu) +![GitHub License](https://img.shields.io/github/license/twilightcoders/rubysu) # Ruby Sudo @@ -139,27 +140,22 @@ end Guido De Rosa ([@gderosa](http://github.com/gderosa/)). -See LICENSE. +See ([LICENSE](https://github.com/TwilightCoders/rubysu/blob/main/LICENSE)). ### Contributors -Dale Stevens ([@voltechs](https://github.com/voltechs)) +- Dale Stevens ([@voltechs](https://github.com/voltechs)) +- Robert M. Koch ([@threadmetal](https://github.com/threadmetal)) +- Wolfgang Teuber ([@wteuber](https://github.com/wteuber)) -Robert M. Koch ([@threadmetal](https://github.com/threadmetal)) +### Acknowledgements -Wolfgang Teuber ([@wteuber](https://github.com/wteuber)) - -### Other acknowledgements - - -Thanks to Tony Arcieri and Brian Candler for suggestions on -[ruby-talk](http://www.ruby-forum.com/topic/262655). - -Initially developed by G. D. while working at [@vemarsas](https://github.com/vemarsas). +- Thanks to Tony Arcieri and Brian Candler for suggestions on [ruby-talk](http://www.ruby-forum.com/topic/262655). +- Initially developed by Guido De Rosa while working at [@vemarsas](https://github.com/vemarsas). ## Contributing -1. Fork it ( https://github.com/TwilightCoders/rubysu/fork ) +1. Fork it ( ) 2. Create your feature branch (`git checkout -b my-new-feature`) 3. Commit your changes (`git commit -am 'Add some feature'`) 4. Push to the branch (`git push origin my-new-feature`) diff --git a/spec/lib/sudo/proxy_spec.rb b/spec/lib/sudo/proxy_spec.rb index a0b9912..2f349a8 100644 --- a/spec/lib/sudo/proxy_spec.rb +++ b/spec/lib/sudo/proxy_spec.rb @@ -1,5 +1,44 @@ require 'spec_helper' +describe Sudo::MethodProxy do + let(:object) { double('test_object') } + let(:proxy) { double('proxy') } + subject { described_class.new(object, proxy) } + + describe '#initialize' do + it 'stores object and proxy references' do + method_proxy = described_class.new(object, proxy) + expect(method_proxy.instance_variable_get(:@object)).to eq(object) + expect(method_proxy.instance_variable_get(:@proxy)).to eq(proxy) + end + end + + describe '#method_missing' do + it 'delegates method calls to proxy' do + expect(proxy).to receive(:proxy).with(object, :test_method, 'arg1', 'arg2') + subject.test_method('arg1', 'arg2') + end + + it 'supports blocks' do + block = proc { 'test' } + expect(proxy).to receive(:proxy).with(object, :test_method, &block) + subject.test_method(&block) + end + end + + describe '#respond_to_missing?' do + it 'returns true if object responds to method' do + allow(object).to receive(:respond_to?).with(:test_method, false).and_return(true) + expect(subject.respond_to?(:test_method)).to be true + end + + it 'returns false if object does not respond to method' do + allow(object).to receive(:respond_to?).with(:unknown_method, false).and_return(false) + expect(subject.respond_to?(:unknown_method)).to be false + end + end +end + describe Sudo::Proxy do it 'proxies the call' do expect(subject.proxy(Kernel)).to eq(Kernel) @@ -11,6 +50,12 @@ expect(subject.loaded_specs).to_not be_empty expect(subject.loaded_specs).to all(be_a(String)) end + + it 'handles errors gracefully' do + allow(Gem).to receive(:loaded_specs).and_raise(StandardError.new('test error')) + allow(subject).to receive(:warn) # Suppress warning output in tests + expect(subject.loaded_specs).to eq([]) + end end context '#load_path' do diff --git a/spec/lib/sudo/system_spec.rb b/spec/lib/sudo/system_spec.rb index f599c44..5c27c8f 100644 --- a/spec/lib/sudo/system_spec.rb +++ b/spec/lib/sudo/system_spec.rb @@ -1,6 +1,98 @@ require 'spec_helper' describe Sudo::System do + describe '#kill' do + it 'does nothing if pid is nil' do + expect(described_class).not_to receive(:system) + described_class.kill(nil) + end + + it 'does nothing if process does not exist' do + allow(Process).to receive(:exists?).with(12345).and_return(false) + expect(described_class).not_to receive(:system) + described_class.kill(12345) + end + + it 'tries kill then kill -9 on failure' do + allow(Process).to receive(:exists?).with(12345).and_return(true) + allow(described_class).to receive(:system).with("sudo", "kill", "12345").and_return(false) + allow(described_class).to receive(:system).with("sudo", "kill", "-9", "12345").and_return(true) + + expect { described_class.kill(12345) }.not_to raise_error + end + + it 'raises ProcessStillExists if both kill attempts fail' do + allow(Process).to receive(:exists?).with(12345).and_return(true) + allow(described_class).to receive(:system).and_return(false) + + expect { described_class.kill(12345) }.to raise_error(Sudo::System::ProcessStillExists) + end + end + + describe '#command' do + it 'builds command array with ruby options and socket' do + allow(described_class).to receive(:command_base).with({}).and_return([['sudo', '-E', 'ruby'], {}]) + + result = described_class.command('-v', '/tmp/socket.sock') + cmd_args, env = result + + expect(cmd_args).to include('-v') + expect(cmd_args).to include('/tmp/socket.sock') + expect(cmd_args).to include(Process.uid.to_s) + end + + it 'handles empty ruby_opts' do + allow(described_class).to receive(:command_base).with({}).and_return([['sudo', '-E', 'ruby'], {}]) + + result = described_class.command('', '/tmp/socket.sock') + cmd_args, _ = result + + expect(cmd_args).not_to include('') + end + end + + describe '#check' do + it 'calls sudo with -e flag to check permissions' do + allow(described_class).to receive(:command_base).and_return([['sudo', '-E', 'ruby'], {}]) + allow(described_class).to receive(:system).with({}, 'sudo', '-E', 'ruby', '-e', '').and_return(true) + + expect { described_class.check }.not_to raise_error + end + + it 'raises SudoFailed when sudo check fails' do + allow(described_class).to receive(:command_base).and_return([['sudo', '-E', 'ruby'], {}]) + allow(described_class).to receive(:system).and_return(false) + + expect { described_class.check }.to raise_error(Sudo::Wrapper::SudoFailed) + end + end + + describe '#command_base' do + it 'builds basic command without askpass' do + cmd_args, env = described_class.send(:command_base) + + expect(cmd_args).to include(Sudo::SUDO_CMD) + expect(cmd_args).to include('-E') + expect(env).to be_empty + end + + it 'adds askpass when configuration is set' do + allow(Sudo).to receive(:configuration).and_return(double(sudo_askpass: '/usr/bin/ssh-askpass')) + + cmd_args, env = described_class.send(:command_base) + + expect(cmd_args).to include('-A') + expect(env['SUDO_ASKPASS']).to eq('/usr/bin/ssh-askpass') + end + + it 'handles custom environment' do + custom_env = { 'CUSTOM_VAR' => 'value' } + cmd_args, env = described_class.send(:command_base, custom_env) + + expect(env['CUSTOM_VAR']).to eq('value') + end + end + context '#unlink' do subject do described_class.unlink('/tmp/foo') @@ -8,6 +100,7 @@ it 'deletes file' do File.open('/tmp/foo', 'w+') + allow(described_class).to receive(:system).with("sudo", "rm", "-f", '/tmp/foo').and_return(true) expect { subject }.to_not raise_exception end @@ -16,5 +109,16 @@ allow(File).to receive(:exist?).and_return(true) expect { described_class.unlink('/tmp/bar') }.to raise_exception(Sudo::System::FileStillExists) end + + it 'does nothing if file does not exist' do + allow(File).to receive(:exist?).with('/tmp/nonexistent').and_return(false) + expect(described_class).not_to receive(:system) + described_class.unlink('/tmp/nonexistent') + end + + it 'does nothing if file path is nil' do + expect(described_class).not_to receive(:system) + described_class.unlink(nil) + end end end diff --git a/spec/lib/sudo/wrapper_spec.rb b/spec/lib/sudo/wrapper_spec.rb index 1231376..939ffeb 100644 --- a/spec/lib/sudo/wrapper_spec.rb +++ b/spec/lib/sudo/wrapper_spec.rb @@ -52,15 +52,15 @@ wrapper_instance = double('wrapper', stop!: nil) allow(described_class).to receive(:new).and_return(wrapper_instance) allow(wrapper_instance).to receive(:start!).and_return(wrapper_instance) - + expect(wrapper_instance).to receive(:stop!) - + described_class.run { |sudo| } end it 'does not raise error when wrapper creation fails and sudo is nil' do allow(described_class).to receive(:new).and_raise(StandardError, "Creation failed") - + # This should not raise NoMethodError due to safe navigation expect { described_class.run { |sudo| } }.to raise_error(StandardError, "Creation failed") end @@ -69,9 +69,9 @@ wrapper_instance = double('wrapper', stop!: nil) allow(described_class).to receive(:new).and_return(wrapper_instance) allow(wrapper_instance).to receive(:start!).and_return(wrapper_instance) - + expect(wrapper_instance).to receive(:stop!) - + expect do described_class.run { |sudo| raise "Block error" } end.to raise_error("Block error") @@ -150,4 +150,166 @@ end end end + + describe 'Error handling and edge cases' do + describe '#start!' do + it 'raises error when socket creation times out' do + allow(Sudo::System).to receive(:check) + allow(Sudo::System).to receive(:command).and_return([['sudo', 'ruby'], {}]) + allow_any_instance_of(Sudo::Wrapper).to receive(:spawn).and_return(1234) + allow(Process).to receive(:detach) + allow_any_instance_of(Sudo::Wrapper).to receive(:wait_for).and_return(false) # timeout + + wrapper = described_class.new + expect { wrapper.start! }.to raise_error(RuntimeError, /Couldn't create DRb socket/) + end + end + + describe '#running?' do + it 'returns false when process does not exist' do + wrapper = described_class.new + wrapper.instance_variable_set(:@sudo_pid, 99999) + wrapper.instance_variable_set(:@proxy, double('proxy')) + allow(wrapper).to receive(:socket?).and_return(true) + allow(Process).to receive(:exists?).with(99999).and_return(false) + + expect(wrapper.running?).to be false + end + + it 'returns false when socket does not exist' do + wrapper = described_class.new + wrapper.instance_variable_set(:@sudo_pid, 1234) + wrapper.instance_variable_set(:@proxy, double('proxy')) + allow(Process).to receive(:exists?).with(1234).and_return(true) + allow(wrapper).to receive(:socket?).and_return(false) + + expect(wrapper.running?).to be false + end + + it 'returns falsy when proxy is nil' do + wrapper = described_class.new + wrapper.instance_variable_set(:@sudo_pid, 1234) + wrapper.instance_variable_set(:@proxy, nil) + allow(Process).to receive(:exists?).with(1234).and_return(true) + allow(wrapper).to receive(:socket?).and_return(true) + + expect(wrapper.running?).to be_falsy + end + end + + describe 'gem loading error handling' do + it 'handles DRb marshaling errors in prospective_gems' do + wrapper = described_class.new + proxy = double('proxy') + wrapper.instance_variable_set(:@proxy, proxy) + + allow(proxy).to receive(:loaded_specs).and_raise(StandardError.new('marshaling error')) + allow(wrapper).to receive(:warn) # Suppress warning output in tests + expect(wrapper.send(:prospective_gems)).to eq([]) + end + + it 'handles LoadError in try_gem_variants' do + wrapper = described_class.new + proxy = double('proxy') + wrapper.instance_variable_set(:@proxy, proxy) + + allow(proxy).to receive(:proxy).with(Kernel, :require, 'test-gem').and_raise(LoadError) + allow(proxy).to receive(:proxy).with(Kernel, :require, 'test/gem').and_raise(LoadError) + + # Should not raise error, just continue + expect { wrapper.send(:try_gem_variants, 'test-gem') }.not_to raise_error + end + + it 'handles NameError in try_gem_variants' do + wrapper = described_class.new + proxy = double('proxy') + wrapper.instance_variable_set(:@proxy, proxy) + + allow(proxy).to receive(:proxy).with(Kernel, :require, 'test-gem').and_raise(NameError) + allow(proxy).to receive(:proxy).with(Kernel, :require, 'test/gem').and_return(true) + + expect { wrapper.send(:try_gem_variants, 'test-gem') }.not_to raise_error + end + + it 'stops trying variants on successful require' do + wrapper = described_class.new + proxy = double('proxy') + wrapper.instance_variable_set(:@proxy, proxy) + + expect(proxy).to receive(:proxy).with(Kernel, :require, 'test-gem').and_return(true).once + expect(proxy).not_to receive(:proxy).with(Kernel, :require, 'test/gem') + + wrapper.send(:try_gem_variants, 'test-gem') + end + end + + describe 'load_paths method' do + it 'adds missing paths from host to proxy' do + wrapper = described_class.new + proxy = double('proxy') + wrapper.instance_variable_set(:@proxy, proxy) + + host_paths = ['/host/path1', '/host/path2', '/shared/path'] + proxy_paths = ['/shared/path', '/proxy/path'] + + allow($LOAD_PATH).to receive(:-).and_return(['/host/path1', '/host/path2']) + allow(proxy).to receive(:load_path).and_return(proxy_paths) + allow(proxy).to receive(:add_load_path).with('/host/path1') + allow(proxy).to receive(:add_load_path).with('/host/path2') + + expect(proxy).to receive(:add_load_path).with('/host/path1') + expect(proxy).to receive(:add_load_path).with('/host/path2') + + wrapper.send(:load_paths) + end + end + + describe 'Finalizer class' do + it 'calls cleanup when invoked' do + data = { pid: 1234, socket: '/tmp/test.sock' } + finalizer = described_class::Finalizer.new(data) + + expect(described_class).to receive(:cleanup!).with(data) + finalizer.call + end + end + + describe '.cleanup!' do + it 'calls System.kill and System.unlink' do + data = { pid: 1234, socket: '/tmp/test.sock' } + + expect(Sudo::System).to receive(:kill).with(1234) + expect(Sudo::System).to receive(:unlink).with('/tmp/test.sock') + + described_class.cleanup!(data) + end + end + + describe '#server_uri' do + it 'returns drbunix URI with socket path' do + wrapper = described_class.new + wrapper.instance_variable_set(:@socket, '/tmp/test.sock') + + expect(wrapper.server_uri).to eq('drbunix:/tmp/test.sock') + end + end + + describe '#socket?' do + it 'returns true when socket file exists' do + wrapper = described_class.new + wrapper.instance_variable_set(:@socket, '/tmp/existing.sock') + allow(File).to receive(:exist?).with('/tmp/existing.sock').and_return(true) + + expect(wrapper.socket?).to be true + end + + it 'returns false when socket file does not exist' do + wrapper = described_class.new + wrapper.instance_variable_set(:@socket, '/tmp/missing.sock') + allow(File).to receive(:exist?).with('/tmp/missing.sock').and_return(false) + + expect(wrapper.socket?).to be false + end + end + end end