├── .github ├── .patch_files ├── .syncignore ├── CODEOWNERS ├── dependabot.yml ├── labels.yml └── workflows │ ├── approve-bot-pr.yml │ ├── codeql-analysis.yml │ ├── create-draft-release.yml │ ├── label-pr.yml │ ├── lint-yaml.yml │ ├── lint.yml │ ├── synchronize-labels.yml │ ├── test-pull-request.yml │ ├── update-github-config.yml │ └── update-go-mod-version.yml ├── .gitignore ├── LICENSE ├── NOTICE ├── README.md ├── bom.go ├── build.go ├── build_metadata.go ├── build_plan.go ├── build_test.go ├── buildpack_info.go ├── buildpack_plan.go ├── cargo ├── buildpack_parser.go ├── buildpack_parser_test.go ├── checksum.go ├── checksum_test.go ├── config.go ├── config_test.go ├── directory_duplicator.go ├── directory_duplicator_test.go ├── extension_config.go ├── extension_config_test.go ├── extension_parser.go ├── extension_parser_test.go ├── init_test.go ├── transport.go ├── transport_test.go ├── validated_reader.go └── validated_reader_test.go ├── chronos ├── clock.go ├── clock_test.go ├── doc.go └── init_test.go ├── detect.go ├── detect_test.go ├── doc.go ├── draft ├── example_test.go ├── init_test.go ├── planner.go └── planner_test.go ├── environment.go ├── environment_test.go ├── fail.go ├── fakes ├── exit_handler.go └── some-executable │ └── main.go ├── fs ├── checksum_calculator.go ├── checksum_calculator_test.go ├── copy.go ├── copy_test.go ├── doc.go ├── exists.go ├── exists_test.go ├── init_test.go ├── is_empty_dir.go ├── is_empty_dir_test.go ├── move.go └── move_test.go ├── generate.go ├── generate_test.go ├── go.mod ├── go.sum ├── init_test.go ├── internal ├── environment_writer.go ├── environment_writer_test.go ├── exit_handler.go ├── exit_handler_test.go ├── fail.go ├── fail_test.go ├── file_writer.go ├── file_writer_test.go ├── init_test.go ├── toml_writer.go └── toml_writer_test.go ├── launch_metadata.go ├── layer.go ├── layer_test.go ├── layers.go ├── layers_test.go ├── matchers ├── contain_lines.go └── match_toml.go ├── option.go ├── paketosbom ├── init_test.go ├── sbom.go └── sbom_test.go ├── pexec ├── doc.go ├── executable.go ├── executable_test.go └── init_test.go ├── platform.go ├── postal ├── buildpack.go ├── doc.go ├── fakes │ ├── mapping_resolver.go │ ├── mirror_resolver.go │ └── transport.go ├── init_test.go ├── internal │ ├── dependency_mappings.go │ ├── dependency_mappings_test.go │ ├── dependency_mirror.go │ ├── dependency_mirror_test.go │ ├── fakes │ │ └── binding_resolver.go │ └── init_test.go ├── service.go └── service_test.go ├── process.go ├── run.go ├── run_test.go ├── sbom ├── formats.go ├── formatted_reader.go ├── formatted_reader_test.go ├── formatter.go ├── formatter_test.go ├── init_test.go ├── sbom.go ├── sbom_format.go ├── sbom_outputs_test.go ├── sbom_test.go └── testdata │ └── package-lock.json ├── sbom_formatter.go ├── sbomgen ├── fakes │ └── executable.go ├── formats.go ├── formats_test.go ├── init_test.go ├── syft_cli_scanner.go └── syft_cli_scanner_test.go ├── scribe ├── color.go ├── color_test.go ├── emitter.go ├── emitter_test.go ├── example_test.go ├── formatted_list.go ├── formatted_list_test.go ├── formatted_map.go ├── formatted_map_test.go ├── init_test.go ├── logger.go ├── logger_test.go ├── scribe.go ├── writer.go └── writer_test.go ├── scripts ├── .util │ ├── print.sh │ └── tools.sh └── unit.sh ├── servicebindings ├── entry.go ├── entry_test.go ├── init_test.go ├── resolver.go ├── resolver_test.go ├── servicebinding.go └── testdata │ └── vcap_services.json ├── slice.go └── vacation ├── archive.go ├── archive_test.go ├── bzip2_archive.go ├── bzip2_archive_test.go ├── example_test.go ├── executable.go ├── executable_test.go ├── gzip_archive.go ├── gzip_archive_test.go ├── init_test.go ├── link_sorting.go ├── link_sorting_test.go ├── nop_archive.go ├── nop_archive_test.go ├── tar_archive.go ├── tar_archive_test.go ├── vacation.go ├── xz_archive.go ├── xz_archive_test.go ├── zip_archive.go ├── zip_archive_test.go └── zipslip.go /.github/.patch_files: -------------------------------------------------------------------------------- 1 | .github/.patch_files 2 | .github/labels.yml 3 | .github/CODEOWNERS 4 | .github/workflows 5 | .github/workflows/approve-bot-pr.yml 6 | .github/workflows/codeql-analysis.yml 7 | .github/workflows/lint.yml 8 | .github/workflows/update-github-config.yml 9 | .github/workflows/create-draft-release.yml 10 | .github/workflows/test-pull-request.yml 11 | .github/workflows/lint-yaml.yml 12 | .github/workflows/synchronize-labels.yml 13 | .github/workflows/label-pr.yml 14 | .github/.syncignore 15 | .github/dependabot.yml 16 | .gitignore 17 | LICENSE 18 | NOTICE 19 | README.md 20 | -------------------------------------------------------------------------------- /.github/.syncignore: -------------------------------------------------------------------------------- 1 | CODEOWNERS 2 | -------------------------------------------------------------------------------- /.github/CODEOWNERS: -------------------------------------------------------------------------------- 1 | * @paketo-buildpacks/tooling-maintainers 2 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | --- 2 | version: 2 3 | updates: 4 | - package-ecosystem: gomod 5 | directory: "/" 6 | schedule: 7 | interval: daily 8 | -------------------------------------------------------------------------------- /.github/labels.yml: -------------------------------------------------------------------------------- 1 | - name: status/possible-priority 2 | description: This issue is ready to work and should be considered as a potential priority 3 | color: F9D0C4 4 | - name: status/prioritized 5 | description: This issue has been triaged and resolving it is a priority 6 | color: BFD4F2 7 | - name: status/blocked 8 | description: This issue has been triaged and resolving it is blocked on some other issue 9 | color: 848978 10 | - name: bug 11 | description: Something isn't working 12 | color: d73a4a 13 | - name: enhancement 14 | description: A new feature or request 15 | color: a2eeef 16 | - name: documentation 17 | description: This issue relates to writing documentation 18 | color: D4C5F9 19 | - name: help wanted 20 | description: Extra attention is needed 21 | color: 008672 22 | - name: semver:major 23 | description: A change requiring a major version bump 24 | color: 6b230e 25 | - name: semver:minor 26 | description: A change requiring a minor version bump 27 | color: cc6749 28 | - name: semver:patch 29 | description: A change requiring a patch version bump 30 | color: f9d0c4 31 | - name: good first issue 32 | description: A good first issue to get started with 33 | color: d3fc03 34 | - name: "failure:release" 35 | description: An issue filed automatically when a release workflow run fails 36 | color: f00a0a 37 | - name: "failure:update-github-config" 38 | description: An issue filed automatically when a github config update workflow run fails 39 | color: f00a0a 40 | - name: "failure:approve-bot-pr" 41 | description: An issue filed automatically when a PR auto-approve workflow run fails 42 | color: f00a0a 43 | -------------------------------------------------------------------------------- /.github/workflows/approve-bot-pr.yml: -------------------------------------------------------------------------------- 1 | name: Approve Bot PRs and Enable Auto-Merge 2 | 3 | on: 4 | workflow_run: 5 | workflows: ["Test Pull Request"] 6 | types: 7 | - completed 8 | 9 | jobs: 10 | download: 11 | name: Download PR Artifact 12 | if: ${{ github.event.workflow_run.event == 'pull_request' && github.event.workflow_run.conclusion == 'success' }} 13 | runs-on: ubuntu-22.04 14 | outputs: 15 | pr-author: ${{ steps.pr-data.outputs.author }} 16 | pr-number: ${{ steps.pr-data.outputs.number }} 17 | steps: 18 | - name: 'Download artifact' 19 | uses: paketo-buildpacks/github-config/actions/pull-request/download-artifact@main 20 | with: 21 | name: "event-payload" 22 | repo: ${{ github.repository }} 23 | run_id: ${{ github.event.workflow_run.id }} 24 | workspace: "/github/workspace" 25 | token: ${{ secrets.PAKETO_BOT_GITHUB_TOKEN }} 26 | - id: pr-data 27 | run: | 28 | echo "author=$(cat event.json | jq -r '.pull_request.user.login')" >> "$GITHUB_OUTPUT" 29 | echo "number=$(cat event.json | jq -r '.pull_request.number')" >> "$GITHUB_OUTPUT" 30 | 31 | approve: 32 | name: Approve Bot PRs 33 | needs: download 34 | if: ${{ needs.download.outputs.pr-author == 'paketo-bot' || needs.download.outputs.pr-author == 'dependabot[bot]' }} 35 | runs-on: ubuntu-22.04 36 | steps: 37 | - name: Check Commit Verification 38 | id: unverified-commits 39 | uses: paketo-buildpacks/github-config/actions/pull-request/check-unverified-commits@main 40 | with: 41 | token: ${{ secrets.PAKETO_BOT_REVIEWER_GITHUB_TOKEN }} 42 | repo: ${{ github.repository }} 43 | number: ${{ needs.download.outputs.pr-number }} 44 | 45 | - name: Check for Human Commits 46 | id: human-commits 47 | uses: paketo-buildpacks/github-config/actions/pull-request/check-human-commits@main 48 | with: 49 | token: ${{ secrets.PAKETO_BOT_REVIEWER_GITHUB_TOKEN }} 50 | repo: ${{ github.repository }} 51 | number: ${{ needs.download.outputs.pr-number }} 52 | 53 | - name: Checkout 54 | if: steps.human-commits.outputs.human_commits == 'false' && steps.unverified-commits.outputs.unverified_commits == 'false' 55 | uses: actions/checkout@v3 56 | 57 | - name: Approve 58 | if: steps.human-commits.outputs.human_commits == 'false' && steps.unverified-commits.outputs.unverified_commits == 'false' 59 | uses: paketo-buildpacks/github-config/actions/pull-request/approve@main 60 | with: 61 | token: ${{ secrets.PAKETO_BOT_REVIEWER_GITHUB_TOKEN }} 62 | number: ${{ needs.download.outputs.pr-number }} 63 | 64 | - name: Enable Auto-Merge 65 | if: steps.human-commits.outputs.human_commits == 'false' && steps.unverified-commits.outputs.unverified_commits == 'false' 66 | run: | 67 | gh pr merge ${{ needs.download.outputs.pr-number }} --auto --rebase 68 | env: 69 | GITHUB_TOKEN: ${{ secrets.PAKETO_BOT_GITHUB_TOKEN }} 70 | 71 | failure: 72 | name: Alert on Failure 73 | runs-on: ubuntu-22.04 74 | needs: [download, approve] 75 | if: ${{ always() && needs.download.result == 'failure' || needs.approve.result == 'failure' }} 76 | steps: 77 | - name: File Failure Alert Issue 78 | uses: paketo-buildpacks/github-config/actions/issue/file@main 79 | with: 80 | token: ${{ secrets.GITHUB_TOKEN }} 81 | repo: ${{ github.repository }} 82 | label: "failure:approve-bot-pr" 83 | comment_if_exists: true 84 | issue_title: "Failure: Approve bot PR workflow" 85 | issue_body: | 86 | Approve bot PR workflow [failed](https://github.com/${{github.repository}}/actions/runs/${{github.run_id}}). 87 | comment_body: | 88 | Another failure occurred: https://github.com/${{github.repository}}/actions/runs/${{github.run_id}} 89 | -------------------------------------------------------------------------------- /.github/workflows/codeql-analysis.yml: -------------------------------------------------------------------------------- 1 | name: "CodeQL" 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | - v* 8 | pull_request: 9 | branches: 10 | - main 11 | - v* 12 | schedule: 13 | - cron: '24 18 * * *' # daily at 18:24 UTC 14 | 15 | jobs: 16 | analyze: 17 | name: Analyze 18 | runs-on: ubuntu-22.04 19 | 20 | strategy: 21 | fail-fast: false 22 | matrix: 23 | language: 24 | - 'go' 25 | 26 | steps: 27 | - name: Checkout repository 28 | uses: actions/checkout@v3 29 | 30 | - name: Initialize CodeQL 31 | uses: github/codeql-action/init@v2 32 | with: 33 | languages: ${{ matrix.language }} 34 | 35 | - name: Autobuild 36 | uses: github/codeql-action/autobuild@v2 37 | 38 | - name: Perform CodeQL Analysis 39 | uses: github/codeql-action/analyze@v2 40 | -------------------------------------------------------------------------------- /.github/workflows/create-draft-release.yml: -------------------------------------------------------------------------------- 1 | name: Create or Update Draft Release 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | - v* 8 | - '!v*-*' 9 | repository_dispatch: 10 | types: [ version-bump ] 11 | workflow_dispatch: 12 | inputs: 13 | version: 14 | description: 'Version of the release to cut (e.g. 1.2.3)' 15 | required: false 16 | 17 | concurrency: release 18 | 19 | jobs: 20 | unit: 21 | name: Unit Tests 22 | runs-on: ubuntu-22.04 23 | steps: 24 | - name: Setup Go 25 | uses: actions/setup-go@v3 26 | with: 27 | go-version: 'stable' 28 | - name: Checkout 29 | uses: actions/checkout@v3 30 | - name: Run Unit Tests 31 | run: ./scripts/unit.sh 32 | 33 | release: 34 | name: Release 35 | runs-on: ubuntu-22.04 36 | needs: unit 37 | steps: 38 | - name: Setup Go 39 | uses: actions/setup-go@v3 40 | with: 41 | go-version: 'stable' 42 | - name: Checkout 43 | uses: actions/checkout@v3 44 | with: 45 | fetch-tags: true 46 | - name: Reset Draft Release 47 | id: reset 48 | uses: paketo-buildpacks/github-config/actions/release/reset-draft@main 49 | with: 50 | repo: ${{ github.repository }} 51 | token: ${{ github.token }} 52 | - name: Calculate Semver Tag 53 | if: github.event.inputs.version == '' 54 | id: semver 55 | uses: paketo-buildpacks/github-config/actions/tag/calculate-semver@main 56 | with: 57 | repo: ${{ github.repository }} 58 | token: ${{ github.token }} 59 | ref-name: ${{ github.ref_name }} 60 | - name: Set Release Tag 61 | id: tag 62 | run: | 63 | tag="${{ github.event.inputs.version }}" 64 | if [ -z "${tag}" ]; then 65 | tag="${{ steps.semver.outputs.tag }}" 66 | fi 67 | echo "tag=${tag}" >> "$GITHUB_OUTPUT" 68 | - name: Create Release 69 | uses: paketo-buildpacks/github-config/actions/release/create@main 70 | with: 71 | repo: ${{ github.repository }} 72 | token: ${{ secrets.PAKETO_BOT_GITHUB_TOKEN }} 73 | tag_name: v${{ steps.tag.outputs.tag }} 74 | target_commitish: ${{ github.sha }} 75 | name: v${{ steps.tag.outputs.tag }} 76 | draft: true 77 | 78 | failure: 79 | name: Alert on Failure 80 | runs-on: ubuntu-22.04 81 | needs: [ unit, release ] 82 | if: ${{ always() && needs.unit.result == 'failure' || needs.release.result == 'failure' }} 83 | steps: 84 | - name: File Failure Alert Issue 85 | uses: paketo-buildpacks/github-config/actions/issue/file@main 86 | with: 87 | token: ${{ secrets.GITHUB_TOKEN }} 88 | repo: ${{ github.repository }} 89 | label: "failure:release" 90 | comment_if_exists: true 91 | issue_title: "Failure: Create Draft Release workflow" 92 | issue_body: | 93 | Create Draft Release workflow [failed](https://github.com/${{github.repository}}/actions/runs/${{github.run_id}}). 94 | comment_body: | 95 | Another failure occurred: https://github.com/${{github.repository}}/actions/runs/${{github.run_id}} 96 | -------------------------------------------------------------------------------- /.github/workflows/label-pr.yml: -------------------------------------------------------------------------------- 1 | name: Set / Validate PR Labels 2 | on: 3 | pull_request_target: 4 | branches: 5 | - main 6 | - v* 7 | types: 8 | - synchronize 9 | - opened 10 | - reopened 11 | - labeled 12 | - unlabeled 13 | 14 | concurrency: pr_labels_${{ github.event.number }} 15 | 16 | jobs: 17 | autolabel: 18 | name: Ensure Minimal Semver Labels 19 | runs-on: ubuntu-22.04 20 | steps: 21 | - name: Check Minimal Semver Labels 22 | uses: mheap/github-action-required-labels@v3 23 | with: 24 | count: 1 25 | labels: semver:major, semver:minor, semver:patch 26 | mode: exactly 27 | env: 28 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 29 | 30 | - name: Auto-label Semver 31 | if: ${{ failure() }} 32 | uses: paketo-buildpacks/github-config/actions/pull-request/auto-semver-label@main 33 | env: 34 | GITHUB_TOKEN: ${{ secrets.PAKETO_BOT_GITHUB_TOKEN }} 35 | -------------------------------------------------------------------------------- /.github/workflows/lint-yaml.yml: -------------------------------------------------------------------------------- 1 | name: Lint Workflows 2 | 3 | on: 4 | pull_request: 5 | paths: 6 | - '.github/**.yml' 7 | - '.github/**.yaml' 8 | 9 | jobs: 10 | lintYaml: 11 | runs-on: ubuntu-22.04 12 | steps: 13 | - uses: actions/checkout@v3 14 | 15 | - name: Checkout github-config 16 | uses: actions/checkout@v3 17 | with: 18 | repository: paketo-buildpacks/github-config 19 | path: github-config 20 | 21 | - name: Set up Python 22 | uses: actions/setup-python@v4 23 | with: 24 | python-version: 3.8 25 | 26 | - name: Install yamllint 27 | run: pip install yamllint 28 | 29 | - name: Lint YAML files 30 | run: yamllint ./.github -c github-config/.github/.yamllint 31 | -------------------------------------------------------------------------------- /.github/workflows/lint.yml: -------------------------------------------------------------------------------- 1 | name: Lint 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | - v* 8 | pull_request: 9 | branches: 10 | - main 11 | - v* 12 | 13 | jobs: 14 | golangci: 15 | name: lint 16 | runs-on: ubuntu-22.04 17 | steps: 18 | - name: Setup Go 19 | uses: actions/setup-go@v3 20 | with: 21 | go-version: 'stable' 22 | 23 | - name: Checkout 24 | uses: actions/checkout@v3 25 | 26 | - name: golangci-lint 27 | uses: golangci/golangci-lint-action@v3 28 | with: 29 | version: latest 30 | args: --timeout 3m0s 31 | -------------------------------------------------------------------------------- /.github/workflows/synchronize-labels.yml: -------------------------------------------------------------------------------- 1 | name: Synchronize Labels 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | - v* 8 | paths: 9 | - .github/labels.yml 10 | workflow_dispatch: {} 11 | 12 | jobs: 13 | synchronize: 14 | name: Synchronize Labels 15 | runs-on: 16 | - ubuntu-22.04 17 | steps: 18 | - uses: actions/checkout@v3 19 | - uses: micnncim/action-label-syncer@v1 20 | env: 21 | GITHUB_TOKEN: ${{ github.token }} 22 | -------------------------------------------------------------------------------- /.github/workflows/test-pull-request.yml: -------------------------------------------------------------------------------- 1 | name: Test Pull Request 2 | 3 | on: 4 | pull_request: 5 | branches: 6 | - main 7 | - v* 8 | 9 | jobs: 10 | unit: 11 | name: Unit Tests 12 | runs-on: ubuntu-22.04 13 | steps: 14 | - name: Setup Go 15 | uses: actions/setup-go@v3 16 | with: 17 | go-version: 'stable' 18 | 19 | - name: Checkout 20 | uses: actions/checkout@v3 21 | 22 | - name: Run Unit Tests 23 | run: ./scripts/unit.sh 24 | 25 | upload: 26 | name: Upload Workflow Event Payload 27 | runs-on: ubuntu-22.04 28 | steps: 29 | - name: Upload Artifact 30 | uses: actions/upload-artifact@v4 31 | with: 32 | name: event-payload 33 | path: ${{ github.event_path }} 34 | -------------------------------------------------------------------------------- /.github/workflows/update-github-config.yml: -------------------------------------------------------------------------------- 1 | name: Update shared github-config 2 | 3 | on: 4 | schedule: 5 | - cron: '16 19 * * *' # daily at 19:16 UTC 6 | workflow_dispatch: {} 7 | 8 | concurrency: github_config_update 9 | 10 | jobs: 11 | build: 12 | name: Create PR to update shared files 13 | runs-on: ubuntu-22.04 14 | steps: 15 | 16 | - name: Checkout 17 | uses: actions/checkout@v3 18 | with: 19 | token: ${{ secrets.PAKETO_BOT_GITHUB_TOKEN }} 20 | 21 | - name: Checkout github-config 22 | uses: actions/checkout@v3 23 | with: 24 | repository: paketo-buildpacks/github-config 25 | path: github-config 26 | 27 | - name: Checkout Branch 28 | uses: paketo-buildpacks/github-config/actions/pull-request/checkout-branch@main 29 | with: 30 | branch: automation/github-config/update 31 | 32 | - name: Run the sync action 33 | uses: paketo-buildpacks/github-config/actions/sync@main 34 | with: 35 | workspace: /github/workspace 36 | config: /github/workspace/github-config/library 37 | 38 | - name: Cleanup 39 | run: rm -rf github-config 40 | 41 | - name: Commit 42 | id: commit 43 | uses: paketo-buildpacks/github-config/actions/pull-request/create-commit@main 44 | with: 45 | message: "Updating github-config" 46 | pathspec: "." 47 | keyid: ${{ secrets.PAKETO_BOT_GPG_SIGNING_KEY_ID }} 48 | key: ${{ secrets.PAKETO_BOT_GPG_SIGNING_KEY }} 49 | 50 | - name: Push Branch 51 | if: ${{ steps.commit.outputs.commit_sha != '' }} 52 | uses: paketo-buildpacks/github-config/actions/pull-request/push-branch@main 53 | with: 54 | branch: automation/github-config/update 55 | 56 | - name: Open Pull Request 57 | if: ${{ steps.commit.outputs.commit_sha != '' }} 58 | uses: paketo-buildpacks/github-config/actions/pull-request/open@main 59 | with: 60 | token: ${{ secrets.PAKETO_BOT_GITHUB_TOKEN }} 61 | title: "Updates github-config" 62 | branch: automation/github-config/update 63 | base: ${{ github.event.repository.default_branch }} 64 | 65 | failure: 66 | name: Alert on Failure 67 | runs-on: ubuntu-22.04 68 | needs: [build] 69 | if: ${{ always() && needs.build.result == 'failure' }} 70 | steps: 71 | - name: File Failure Alert Issue 72 | uses: paketo-buildpacks/github-config/actions/issue/file@main 73 | with: 74 | token: ${{ secrets.GITHUB_TOKEN }} 75 | repo: ${{ github.repository }} 76 | label: "failure:update-github-config" 77 | comment_if_exists: true 78 | issue_title: "Failure: Update GitHub config workflow" 79 | issue_body: | 80 | Update GitHub config workflow [failed](https://github.com/${{github.repository}}/actions/runs/${{github.run_id}}). 81 | comment_body: | 82 | Another failure occurred: https://github.com/${{github.repository}}/actions/runs/${{github.run_id}} 83 | -------------------------------------------------------------------------------- /.github/workflows/update-go-mod-version.yml: -------------------------------------------------------------------------------- 1 | name: Update Go version 2 | 3 | on: 4 | schedule: 5 | - cron: '19 3 * * MON' # every monday at 3:19 UTC 6 | workflow_dispatch: 7 | 8 | concurrency: update-go 9 | 10 | jobs: 11 | update-go: 12 | name: Update go toolchain in go.mod 13 | runs-on: ubuntu-latest 14 | steps: 15 | - name: Check out code 16 | uses: actions/checkout@v4 17 | - name: Checkout PR Branch 18 | uses: paketo-buildpacks/github-config/actions/pull-request/checkout-branch@main 19 | with: 20 | branch: automation/go-mod-update/update-main 21 | - name: Setup Go 22 | id: setup-go 23 | uses: actions/setup-go@v5 24 | with: 25 | go-version: 'stable' 26 | - name: Get current go toolchain version 27 | id: current-go-version 28 | uses: paketo-buildpacks/github-config/actions/update-go-mod-version@main 29 | with: 30 | go-version: ${{ steps.setup-go.outputs.go-version }} 31 | - name: Go mod tidy 32 | run: | 33 | #!/usr/bin/env bash 34 | set -euo pipefail 35 | shopt -s inherit_errexit 36 | 37 | echo "Before running go mod tidy" 38 | echo "head -n10 go.mod " 39 | head -n10 go.mod 40 | 41 | echo "git diff" 42 | git diff 43 | 44 | echo "Running go mod tidy" 45 | go mod tidy 46 | 47 | echo "After running go mod tidy" 48 | echo "head -n10 go.mod " 49 | head -n10 go.mod 50 | 51 | echo "git diff" 52 | git diff 53 | - name: Commit 54 | id: commit 55 | uses: paketo-buildpacks/github-config/actions/pull-request/create-commit@main 56 | with: 57 | message: "Updates go mod version to ${{ steps.setup-go.outputs.go-version }}" 58 | pathspec: "." 59 | keyid: ${{ secrets.PAKETO_BOT_GPG_SIGNING_KEY_ID }} 60 | key: ${{ secrets.PAKETO_BOT_GPG_SIGNING_KEY }} 61 | 62 | - name: Push Branch 63 | if: ${{ steps.commit.outputs.commit_sha != '' }} 64 | uses: paketo-buildpacks/github-config/actions/pull-request/push-branch@main 65 | with: 66 | branch: automation/go-mod-update/update-main 67 | 68 | - name: Open Pull Request 69 | if: ${{ steps.commit.outputs.commit_sha != '' }} 70 | uses: paketo-buildpacks/github-config/actions/pull-request/open@main 71 | with: 72 | token: ${{ secrets.PAKETO_BOT_GITHUB_TOKEN }} 73 | title: "Updates go mod version to ${{ steps.setup-go.outputs.go-version }}" 74 | branch: automation/go-mod-update/update-main 75 | base: ${{ github.event.repository.default_branch }} 76 | 77 | failure: 78 | name: Alert on Failure 79 | runs-on: ubuntu-22.04 80 | needs: [update-go] 81 | if: ${{ always() && needs.update-go.result == 'failure' }} 82 | steps: 83 | - name: File Failure Alert Issue 84 | uses: paketo-buildpacks/github-config/actions/issue/file@main 85 | with: 86 | token: ${{ secrets.GITHUB_TOKEN }} 87 | repo: ${{ github.repository }} 88 | label: "failure:update-go-version" 89 | comment_if_exists: true 90 | issue_title: "Failure: Update Go Mod Version workflow" 91 | issue_body: | 92 | Update Go Mod Version workflow [failed](https://github.com/${{github.repository}}/actions/runs/${{github.run_id}}). 93 | comment_body: | 94 | Another failure occurred: https://github.com/${{github.repository}}/actions/runs/${{github.run_id}} 95 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | .idea 3 | coverage.out 4 | **/*.tar 5 | -------------------------------------------------------------------------------- /NOTICE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2013-Present CloudFoundry.org Foundation, Inc. All Rights Reserved. 2 | 3 | Licensed under the Apache License, Version 2.0 (the "License"); 4 | you may not use this file except in compliance with the License. 5 | You may obtain a copy of the License at 6 | 7 | http://www.apache.org/licenses/LICENSE-2.0 8 | 9 | Unless required by applicable law or agreed to in writing, software 10 | distributed under the License is distributed on an "AS IS" BASIS, 11 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | See the License for the specific language governing permissions and 13 | limitations under the License. 14 | -------------------------------------------------------------------------------- /bom.go: -------------------------------------------------------------------------------- 1 | package packit 2 | 3 | // BOMEntry contains a bill of materials entry. 4 | type BOMEntry struct { 5 | // Name represents the name of the entry. 6 | Name string `toml:"name"` 7 | 8 | // Metadata is the metadata of the entry. Optional. 9 | Metadata interface{} `toml:"metadata,omitempty"` 10 | } 11 | 12 | // UnmetEntry contains the name of an unmet dependency from the build process 13 | type UnmetEntry struct { 14 | // Name represents the name of the entry. 15 | Name string `toml:"name"` 16 | } 17 | -------------------------------------------------------------------------------- /build_metadata.go: -------------------------------------------------------------------------------- 1 | package packit 2 | 3 | // BuildMetadata represents the build metadata details persisted in the 4 | // build.toml file according to the buildpack lifecycle specification: 5 | // https://github.com/buildpacks/spec/blob/main/buildpack.md#buildtoml-toml. 6 | type BuildMetadata struct { 7 | // BOM is the Bill-of-Material entries containing information about the 8 | // dependencies provided to the build environment. 9 | BOM []BOMEntry `toml:"bom"` 10 | 11 | // SBOM is a type that implements SBOMFormatter and declares the formats that 12 | // bill-of-materials should be output for the build SBoM. 13 | SBOM SBOMFormatter `toml:"-"` 14 | 15 | // Unmet is a list of unmet entries from the build process that it was unable 16 | // to provide. 17 | Unmet []UnmetEntry `toml:"unmet"` 18 | } 19 | 20 | func (b BuildMetadata) isEmpty() bool { 21 | var sbom []SBOMFormat 22 | if b.SBOM != nil { 23 | sbom = b.SBOM.Formats() 24 | } 25 | 26 | return len(sbom)+len(b.BOM)+len(b.Unmet) == 0 27 | } 28 | -------------------------------------------------------------------------------- /build_plan.go: -------------------------------------------------------------------------------- 1 | package packit 2 | 3 | // BuildPlan is a representation of the Build Plan as specified in the 4 | // specification: 5 | // https://github.com/buildpacks/spec/blob/main/buildpack.md#build-plan-toml. 6 | // The BuildPlan allows buildpacks to indicate what dependencies they provide 7 | // or require. 8 | type BuildPlan struct { 9 | // Provides is a list of BuildPlanProvisions that are provided by this 10 | // buildpack. 11 | Provides []BuildPlanProvision `toml:"provides"` 12 | 13 | // Requires is a list of BuildPlanRequirements that are required by this 14 | // buildpack. 15 | Requires []BuildPlanRequirement `toml:"requires"` 16 | 17 | // Or is a list of additional BuildPlans that may be selected by the 18 | // lifecycle 19 | Or []BuildPlan `toml:"or,omitempty"` 20 | } 21 | 22 | // BuildPlanProvision is a representation of a dependency that can be provided 23 | // by a buildpack. 24 | type BuildPlanProvision struct { 25 | // Name is the identifier whereby buildpacks can coordinate that a dependency 26 | // is provided or required. 27 | Name string `toml:"name"` 28 | } 29 | 30 | type BuildPlanRequirement struct { 31 | // Name is the identifier whereby buildpacks can coordinate that a dependency 32 | // is provided or required. 33 | Name string `toml:"name"` 34 | 35 | // Metadata is an unspecified field allowing buildpacks to communicate extra 36 | // details about their requirement. Examples of this type of metadata might 37 | // include details about what source was used to decide the version 38 | // constraint for a requirement. 39 | Metadata interface{} `toml:"metadata"` 40 | } 41 | -------------------------------------------------------------------------------- /buildpack_info.go: -------------------------------------------------------------------------------- 1 | package packit 2 | 3 | // BuildpackInfo 4 | // Deprecated: use Info instead 5 | type BuildpackInfo = Info 6 | 7 | // Info is a representation of the basic information for a buildpack 8 | // provided in its buildpack.toml file as described in the specification: 9 | // https://github.com/buildpacks/spec/blob/main/buildpack.md#buildpacktoml-toml. 10 | type Info struct { 11 | // ID is the identifier specified in the `buildpack.id` field of the 12 | // buildpack.toml. 13 | ID string `toml:"id"` 14 | 15 | // Name is the identifier specified in the `buildpack.name` field of the 16 | // buildpack.toml. 17 | Name string `toml:"name"` 18 | 19 | // Version is the identifier specified in the `buildpack.version` field of 20 | // the buildpack.toml. 21 | Version string `toml:"version"` 22 | 23 | // Homepage is the identifier specified in the `buildpack.homepage` field of 24 | // the buildpack.toml. 25 | Homepage string `toml:"homepage"` 26 | 27 | // Description is the identifier specified in the `buildpack.description` 28 | // field of the buildpack.toml. 29 | Description string `toml:"description"` 30 | 31 | // Keywords are the identifiers specified in the `buildpack.keywords` field 32 | // of the buildpack.toml. 33 | Keywords []string `toml:"keywords"` 34 | 35 | // Licenses are the list of licenses specified in the `buildpack.licenses` 36 | // fields of the buildpack.toml. 37 | Licenses []InfoLicense 38 | 39 | // SBOMFormats is the list of Software Bill of Materials media types that the buildpack 40 | // produces (e.g. "application/spdx+json"). 41 | SBOMFormats []string `toml:"sbom-formats"` 42 | } 43 | 44 | // type BuildpackInfoLicense 45 | // Deprecated: use InfoLicense instead 46 | type BuildpackInfoLicense = InfoLicense 47 | 48 | // InfoLicense is a representation of a license specified in the 49 | // buildpack.toml as described in the specification: 50 | // https://github.com/buildpacks/spec/blob/main/buildpack.md#buildpacktoml-toml. 51 | type InfoLicense struct { 52 | // Type is the identifier specified in the `buildpack.licenses.type` field of 53 | // the buildpack.toml. 54 | Type string `toml:"type"` 55 | 56 | // URI is the identifier specified in the `buildpack.licenses.uri` field of 57 | // the buildpack.toml. 58 | URI string `toml:"uri"` 59 | } 60 | -------------------------------------------------------------------------------- /buildpack_plan.go: -------------------------------------------------------------------------------- 1 | package packit 2 | 3 | // BuildpackPlan is a representation of the buildpack plan provided by the 4 | // lifecycle and defined in the specification: 5 | // https://github.com/buildpacks/spec/blob/main/buildpack.md#buildpack-plan-toml. 6 | // It is also used to return a set of refinements to the plan at the end of the 7 | // build phase. 8 | type BuildpackPlan struct { 9 | // Entries is a list of BuildpackPlanEntry fields that are declared in the 10 | // buildpack plan TOML file. 11 | Entries []BuildpackPlanEntry `toml:"entries"` 12 | } 13 | 14 | // BuildpackPlanEntry is a representation of a single buildpack plan entry 15 | // specified by the lifecycle. 16 | type BuildpackPlanEntry struct { 17 | // Name is the name of the dependency the the buildpack should provide. 18 | Name string `toml:"name"` 19 | 20 | // Metadata is an unspecified field allowing buildpacks to communicate extra 21 | // details about their requirement. Examples of this type of metadata might 22 | // include details about what source was used to decide the version 23 | // constraint for a requirement. 24 | Metadata map[string]interface{} `toml:"metadata"` 25 | } 26 | -------------------------------------------------------------------------------- /cargo/buildpack_parser.go: -------------------------------------------------------------------------------- 1 | package cargo 2 | 3 | import "os" 4 | 5 | type BuildpackParser struct{} 6 | 7 | func NewBuildpackParser() BuildpackParser { 8 | return BuildpackParser{} 9 | } 10 | 11 | func (p BuildpackParser) Parse(path string) (Config, error) { 12 | file, err := os.Open(path) 13 | if err != nil { 14 | return Config{}, err 15 | } 16 | 17 | var config Config 18 | err = DecodeConfig(file, &config) 19 | if err != nil { 20 | return Config{}, err 21 | } 22 | 23 | return config, nil 24 | } 25 | -------------------------------------------------------------------------------- /cargo/buildpack_parser_test.go: -------------------------------------------------------------------------------- 1 | package cargo_test 2 | 3 | import ( 4 | "os" 5 | "testing" 6 | "time" 7 | 8 | "github.com/paketo-buildpacks/packit/v2/cargo" 9 | "github.com/sclevine/spec" 10 | 11 | . "github.com/onsi/gomega" 12 | ) 13 | 14 | func testBuildpackParser(t *testing.T, context spec.G, it spec.S) { 15 | var ( 16 | Expect = NewWithT(t).Expect 17 | 18 | path string 19 | parser cargo.BuildpackParser 20 | ) 21 | 22 | it.Before(func() { 23 | file, err := os.CreateTemp("", "buildpack.toml") 24 | Expect(err).NotTo(HaveOccurred()) 25 | 26 | _, err = file.WriteString(`api = "0.2" 27 | [buildpack] 28 | id = "some-buildpack-id" 29 | name = "some-buildpack-name" 30 | version = "some-buildpack-version" 31 | 32 | [metadata] 33 | include-files = ["some-include-file", "other-include-file"] 34 | pre-package = "some-pre-package-script.sh" 35 | 36 | [[metadata.dependencies]] 37 | deprecation_date = 2020-06-01T00:00:00Z 38 | id = "some-dependency" 39 | name = "Some Dependency" 40 | sha256 = "shasum" 41 | source = "source" 42 | source_sha256 = "source-shasum" 43 | stacks = ["io.buildpacks.stacks.bionic", "org.cloudfoundry.stacks.tiny"] 44 | uri = "http://some-url" 45 | version = "1.2.3" 46 | `) 47 | Expect(err).NotTo(HaveOccurred()) 48 | 49 | Expect(file.Close()).To(Succeed()) 50 | 51 | path = file.Name() 52 | 53 | parser = cargo.NewBuildpackParser() 54 | }) 55 | 56 | it.After(func() { 57 | Expect(os.RemoveAll(path)).To(Succeed()) 58 | }) 59 | 60 | context("Parse", func() { 61 | it("parses a given buildpack.toml", func() { 62 | deprecationDate, err := time.Parse(time.RFC3339, "2020-06-01T00:00:00Z") 63 | Expect(err).NotTo(HaveOccurred()) 64 | config, err := parser.Parse(path) 65 | Expect(err).NotTo(HaveOccurred()) 66 | Expect(config).To(Equal(cargo.Config{ 67 | API: "0.2", 68 | Buildpack: cargo.ConfigBuildpack{ 69 | ID: "some-buildpack-id", 70 | Name: "some-buildpack-name", 71 | Version: "some-buildpack-version", 72 | }, 73 | Metadata: cargo.ConfigMetadata{ 74 | IncludeFiles: []string{ 75 | "some-include-file", 76 | "other-include-file", 77 | }, 78 | PrePackage: "some-pre-package-script.sh", 79 | Dependencies: []cargo.ConfigMetadataDependency{ 80 | { 81 | DeprecationDate: &deprecationDate, 82 | ID: "some-dependency", 83 | Name: "Some Dependency", 84 | SHA256: "shasum", 85 | Source: "source", 86 | SourceSHA256: "source-shasum", 87 | Stacks: []string{"io.buildpacks.stacks.bionic", "org.cloudfoundry.stacks.tiny"}, 88 | URI: "http://some-url", 89 | Version: "1.2.3", 90 | }, 91 | }, 92 | }, 93 | })) 94 | }) 95 | 96 | context("when the buildpack.toml does not exist", func() { 97 | it.Before(func() { 98 | Expect(os.Remove(path)).To(Succeed()) 99 | }) 100 | 101 | it("returns an error", func() { 102 | _, err := parser.Parse(path) 103 | Expect(err).To(MatchError(ContainSubstring("no such file or directory"))) 104 | }) 105 | }) 106 | 107 | context("when the buildpack.toml is malformed", func() { 108 | it.Before(func() { 109 | Expect(os.WriteFile(path, []byte("%%%"), 0644)).To(Succeed()) 110 | }) 111 | 112 | it("returns an error", func() { 113 | _, err := parser.Parse(path) 114 | Expect(err).To(MatchError(ContainSubstring("expected '.' or '=', but got '%' instead"))) 115 | }) 116 | }) 117 | }) 118 | } 119 | -------------------------------------------------------------------------------- /cargo/checksum.go: -------------------------------------------------------------------------------- 1 | package cargo 2 | 3 | import ( 4 | "strings" 5 | ) 6 | 7 | // Checksum represents a checksum algorithm and hash pair formatted as 8 | // algorithm:hash. 9 | type Checksum string 10 | 11 | // Algorithm returns the algorithm portion of the checksum string. If that 12 | // portion is missing, it defaults to "sha256". 13 | func (c Checksum) Algorithm() string { 14 | algorithm, _, found := strings.Cut(string(c), ":") 15 | if !found { 16 | return "sha256" 17 | } 18 | 19 | return algorithm 20 | } 21 | 22 | // Hash returns the hexidecimal encoded hash portion of the checksum string. 23 | func (c Checksum) Hash() string { 24 | _, hash, found := strings.Cut(string(c), ":") 25 | if !found { 26 | hash = string(c) 27 | } 28 | 29 | return hash 30 | } 31 | 32 | // EqualTo returns true only when the given checksum algorithms and hashes 33 | // match. 34 | func (c Checksum) Match(o Checksum) bool { 35 | return strings.EqualFold(c.Algorithm(), o.Algorithm()) && c.Hash() == o.Hash() 36 | } 37 | 38 | // EqualTo returns true only when the given checksum formatted string 39 | // algorithms and hashes match. 40 | func (c Checksum) MatchString(o string) bool { 41 | return c.Match(Checksum(o)) 42 | } 43 | -------------------------------------------------------------------------------- /cargo/checksum_test.go: -------------------------------------------------------------------------------- 1 | package cargo_test 2 | 3 | import ( 4 | "fmt" 5 | "testing" 6 | 7 | "github.com/paketo-buildpacks/packit/v2/cargo" 8 | "github.com/sclevine/spec" 9 | 10 | . "github.com/onsi/gomega" 11 | ) 12 | 13 | func testChecksum(t *testing.T, context spec.G, it spec.S) { 14 | Expect := NewWithT(t).Expect 15 | 16 | context("Matching", func() { 17 | type testCaseType struct { 18 | c1 string 19 | c2 string 20 | result bool 21 | } 22 | 23 | for _, tc := range []testCaseType{ 24 | {"", "", true}, 25 | {"c", "c", true}, 26 | {"sha256:c", "c", true}, 27 | {"c", "sha256:c", true}, 28 | {"md5:c", "md5:c", true}, 29 | {"md5:c", ":c", false}, 30 | {":", ":", true}, 31 | {":c", ":c", true}, 32 | {"", "c", false}, 33 | {"c", "", false}, 34 | {"c", "z", false}, 35 | {"md5:c", "sha256:c", false}, 36 | {"md5:c", "md5:d", false}, 37 | {"md5:c:d", "md5:c:d", true}, 38 | {"md5:c", "md5:c:d", false}, 39 | {":", "::", false}, 40 | {":", ":::", false}, 41 | } { 42 | 43 | // NOTE: we need to keep a "loop-local" variable to use in the "it 44 | // function closure" below, otherwise the value of tc will simply be the 45 | // last element in the slice every time the test is evaluated. 46 | ca, cb, sb, result := cargo.Checksum(tc.c1), cargo.Checksum(tc.c2), tc.c2, tc.result 47 | 48 | it(fmt.Sprintf("will check result %q == %q", ca, cb), func() { 49 | Expect(ca.Match(cb)).To(Equal(result)) 50 | Expect(ca.MatchString(sb)).To(Equal(result)) 51 | }) 52 | } 53 | }) 54 | } 55 | -------------------------------------------------------------------------------- /cargo/directory_duplicator.go: -------------------------------------------------------------------------------- 1 | package cargo 2 | 3 | import "github.com/paketo-buildpacks/packit/v2/fs" 4 | 5 | type DirectoryDuplicator struct{} 6 | 7 | func NewDirectoryDuplicator() DirectoryDuplicator { 8 | return DirectoryDuplicator{} 9 | } 10 | 11 | func (d DirectoryDuplicator) Duplicate(source, destination string) error { 12 | return fs.Copy(source, destination) 13 | } 14 | -------------------------------------------------------------------------------- /cargo/directory_duplicator_test.go: -------------------------------------------------------------------------------- 1 | package cargo_test 2 | 3 | import ( 4 | "io" 5 | "os" 6 | "path/filepath" 7 | "testing" 8 | 9 | "github.com/paketo-buildpacks/packit/v2/cargo" 10 | "github.com/sclevine/spec" 11 | 12 | . "github.com/onsi/gomega" 13 | ) 14 | 15 | func testDirectoryDuplicator(t *testing.T, context spec.G, it spec.S) { 16 | var ( 17 | Expect = NewWithT(t).Expect 18 | destDir string 19 | sourceDir string 20 | directoryDup cargo.DirectoryDuplicator 21 | ) 22 | 23 | it.Before(func() { 24 | var err error 25 | 26 | sourceDir, err = os.MkdirTemp("", "source") 27 | Expect(err).NotTo(HaveOccurred()) 28 | 29 | Expect(os.WriteFile(filepath.Join(sourceDir, "some-file"), []byte("some content"), 0644)).To(Succeed()) 30 | 31 | Expect(os.MkdirAll(filepath.Join(sourceDir, "some-dir"), os.ModePerm)).To(Succeed()) 32 | Expect(os.WriteFile(filepath.Join(sourceDir, "some-dir", "other-file"), []byte("other content"), 0755)).To(Succeed()) 33 | Expect(os.Symlink("other-file", filepath.Join(sourceDir, "some-dir", "link"))).To(Succeed()) 34 | 35 | destDir, err = os.MkdirTemp("", "dest") 36 | Expect(err).NotTo(HaveOccurred()) 37 | 38 | directoryDup = cargo.NewDirectoryDuplicator() 39 | }) 40 | 41 | it.After(func() { 42 | Expect(os.RemoveAll(sourceDir)).To(Succeed()) 43 | Expect(os.RemoveAll(destDir)).To(Succeed()) 44 | }) 45 | 46 | context("Duplicate", func() { 47 | it("duplicates the contents of a directory", func() { 48 | Expect(directoryDup.Duplicate(sourceDir, destDir)).To(Succeed()) 49 | 50 | file, err := os.Open(filepath.Join(destDir, "some-file")) 51 | Expect(err).NotTo(HaveOccurred()) 52 | 53 | content, err := io.ReadAll(file) 54 | Expect(err).NotTo(HaveOccurred()) 55 | Expect(string(content)).To(Equal("some content")) 56 | 57 | info, err := file.Stat() 58 | Expect(err).NotTo(HaveOccurred()) 59 | Expect(info.Mode()).To(Equal(os.FileMode(0644))) 60 | 61 | Expect(file.Close()).To(Succeed()) 62 | 63 | info, err = os.Stat(filepath.Join(destDir, "some-dir")) 64 | Expect(err).NotTo(HaveOccurred()) 65 | Expect(info.IsDir()).To(BeTrue()) 66 | 67 | file, err = os.Open(filepath.Join(destDir, "some-dir", "other-file")) 68 | Expect(err).NotTo(HaveOccurred()) 69 | 70 | content, err = io.ReadAll(file) 71 | Expect(err).NotTo(HaveOccurred()) 72 | Expect(string(content)).To(Equal("other content")) 73 | 74 | info, err = file.Stat() 75 | Expect(err).NotTo(HaveOccurred()) 76 | Expect(info.Mode()).To(Equal(os.FileMode(0755))) 77 | 78 | Expect(file.Close()).To(Succeed()) 79 | 80 | info, err = os.Lstat(filepath.Join(destDir, "some-dir", "link")) 81 | Expect(err).NotTo(HaveOccurred()) 82 | Expect(info.Mode() & os.ModeSymlink).To(Equal(os.ModeSymlink)) 83 | 84 | path, err := os.Readlink(filepath.Join(destDir, "some-dir", "link")) 85 | Expect(err).NotTo(HaveOccurred()) 86 | Expect(path).To(Equal(filepath.Join("other-file"))) 87 | }) 88 | }) 89 | 90 | context("failure cases", func() { 91 | context("source dir does not exist", func() { 92 | it("fails", func() { 93 | err := directoryDup.Duplicate("does-not-exist", destDir) 94 | Expect(err).To(MatchError(ContainSubstring("no such file or directory"))) 95 | }) 96 | }) 97 | 98 | context("when source file has bad permissions", func() { 99 | it.Before(func() { 100 | Expect(os.Chmod(filepath.Join(sourceDir, "some-file"), 0000)).To(Succeed()) 101 | }) 102 | 103 | it.After(func() { 104 | Expect(os.Chmod(filepath.Join(sourceDir, "some-file"), 0644)).To(Succeed()) 105 | }) 106 | 107 | it("fails", func() { 108 | err := directoryDup.Duplicate(sourceDir, destDir) 109 | Expect(err).To(MatchError(ContainSubstring("permission denied"))) 110 | }) 111 | }) 112 | }) 113 | } 114 | -------------------------------------------------------------------------------- /cargo/extension_parser.go: -------------------------------------------------------------------------------- 1 | package cargo 2 | 3 | import "os" 4 | 5 | type ExtensionParser struct{} 6 | 7 | func NewExtensionParser() ExtensionParser { 8 | return ExtensionParser{} 9 | } 10 | 11 | func (p ExtensionParser) Parse(path string) (ExtensionConfig, error) { 12 | file, err := os.Open(path) 13 | if err != nil { 14 | return ExtensionConfig{}, err 15 | } 16 | 17 | var config ExtensionConfig 18 | err = DecodeExtensionConfig(file, &config) 19 | if err != nil { 20 | return ExtensionConfig{}, err 21 | } 22 | 23 | return config, nil 24 | } 25 | -------------------------------------------------------------------------------- /cargo/extension_parser_test.go: -------------------------------------------------------------------------------- 1 | package cargo_test 2 | 3 | import ( 4 | "os" 5 | "testing" 6 | 7 | "github.com/paketo-buildpacks/packit/v2/cargo" 8 | "github.com/sclevine/spec" 9 | 10 | . "github.com/onsi/gomega" 11 | ) 12 | 13 | func testExtensionParser(t *testing.T, context spec.G, it spec.S) { 14 | var ( 15 | Expect = NewWithT(t).Expect 16 | 17 | path string 18 | parser cargo.ExtensionParser 19 | ) 20 | 21 | it.Before(func() { 22 | file, err := os.CreateTemp("", "extension.toml") 23 | Expect(err).NotTo(HaveOccurred()) 24 | 25 | _, err = file.WriteString(`api = "0.7" 26 | [extension] 27 | id = "some-extension-id" 28 | name = "some-extension-name" 29 | version = "some-extension-version" 30 | 31 | [metadata] 32 | include-files = ["some-include-file", "other-include-file"] 33 | pre-package = "some-pre-package-script.sh" 34 | 35 | [[metadata.some-map]] 36 | key = "value" 37 | 38 | [[metadata.dependencies]] 39 | id = "some-dependency" 40 | name = "Some Dependency" 41 | sha256 = "shasum" 42 | source = "source" 43 | source_sha256 = "source-shasum" 44 | stacks = ["io.buildpacks.stacks.bionic", "org.cloudfoundry.stacks.tiny"] 45 | uri = "http://some-url" 46 | version = "1.2.3" 47 | `) 48 | Expect(err).NotTo(HaveOccurred()) 49 | 50 | Expect(file.Close()).To(Succeed()) 51 | 52 | path = file.Name() 53 | 54 | parser = cargo.NewExtensionParser() 55 | }) 56 | 57 | it.After(func() { 58 | Expect(os.RemoveAll(path)).To(Succeed()) 59 | }) 60 | 61 | context("Parse", func() { 62 | it("parses a given extension.toml", func() { 63 | config, err := parser.Parse(path) 64 | Expect(err).NotTo(HaveOccurred()) 65 | Expect(config).To(Equal(cargo.ExtensionConfig{ 66 | API: "0.7", 67 | Extension: cargo.ConfigExtension{ 68 | ID: "some-extension-id", 69 | Name: "some-extension-name", 70 | Version: "some-extension-version", 71 | }, 72 | Metadata: cargo.ConfigExtensionMetadata{ 73 | IncludeFiles: []string{ 74 | "some-include-file", 75 | "other-include-file", 76 | }, 77 | PrePackage: "some-pre-package-script.sh", 78 | Dependencies: []cargo.ConfigExtensionMetadataDependency{ 79 | { 80 | ID: "some-dependency", 81 | Name: "Some Dependency", 82 | SHA256: "shasum", 83 | Source: "source", 84 | SourceSHA256: "source-shasum", 85 | Stacks: []string{"io.buildpacks.stacks.bionic", "org.cloudfoundry.stacks.tiny"}, 86 | URI: "http://some-url", 87 | Version: "1.2.3", 88 | }, 89 | }, 90 | }, 91 | })) 92 | }) 93 | 94 | context("when the extension.toml does not exist", func() { 95 | it.Before(func() { 96 | Expect(os.Remove(path)).To(Succeed()) 97 | }) 98 | 99 | it("returns an error", func() { 100 | _, err := parser.Parse(path) 101 | Expect(err).To(MatchError(ContainSubstring("no such file or directory"))) 102 | }) 103 | }) 104 | 105 | context("when the extension.toml is malformed", func() { 106 | it.Before(func() { 107 | Expect(os.WriteFile(path, []byte("%%%"), 0644)).To(Succeed()) 108 | }) 109 | 110 | it("returns an error", func() { 111 | _, err := parser.Parse(path) 112 | Expect(err).To(MatchError(ContainSubstring("expected '.' or '=', but got '%' instead"))) 113 | }) 114 | }) 115 | }) 116 | } 117 | -------------------------------------------------------------------------------- /cargo/init_test.go: -------------------------------------------------------------------------------- 1 | package cargo_test 2 | 3 | import ( 4 | "errors" 5 | "testing" 6 | 7 | "github.com/sclevine/spec" 8 | "github.com/sclevine/spec/report" 9 | ) 10 | 11 | func TestUnitCargo(t *testing.T) { 12 | suite := spec.New("cargo", spec.Report(report.Terminal{})) 13 | suite("BuildpackParser", testBuildpackParser) 14 | suite("ExtensionParser", testExtensionParser) 15 | suite("Config", testConfig) 16 | suite("ExtensionConfig", testExtensionConfig) 17 | suite("DirectoryDuplicator", testDirectoryDuplicator) 18 | suite("Transport", testTransport) 19 | suite("ValidatedReader", testValidatedReader) 20 | suite("Checksum", testChecksum) 21 | suite.Run(t) 22 | } 23 | 24 | type errorReader struct{} 25 | 26 | func (r errorReader) Read(p []byte) (int, error) { 27 | return 0, errors.New("failed to read") 28 | } 29 | -------------------------------------------------------------------------------- /cargo/transport.go: -------------------------------------------------------------------------------- 1 | package cargo 2 | 3 | import ( 4 | "fmt" 5 | "io" 6 | "net/http" 7 | "os" 8 | "path/filepath" 9 | "strings" 10 | ) 11 | 12 | type Transport struct{} 13 | 14 | func NewTransport() Transport { 15 | return Transport{} 16 | } 17 | 18 | func (t Transport) Drop(root, uri string) (io.ReadCloser, error) { 19 | if strings.HasPrefix(uri, "file://") { 20 | file, err := os.Open(filepath.Join(root, strings.TrimPrefix(uri, "file://"))) 21 | if err != nil { 22 | return nil, fmt.Errorf("failed to open file: %s", err) 23 | } 24 | 25 | return file, nil 26 | } 27 | 28 | request, err := http.NewRequest("GET", uri, nil) 29 | if err != nil { 30 | return nil, fmt.Errorf("failed to parse request uri: %s", err) 31 | } 32 | 33 | response, err := http.DefaultClient.Do(request) 34 | if err != nil { 35 | return nil, fmt.Errorf("failed to make request: %s", err) 36 | } 37 | 38 | if response.StatusCode >= 400 { 39 | response.Body.Close() 40 | return nil, fmt.Errorf("unexpected status code %d while fetching %q", response.StatusCode, uri) 41 | } 42 | 43 | return response.Body, nil 44 | } 45 | -------------------------------------------------------------------------------- /cargo/transport_test.go: -------------------------------------------------------------------------------- 1 | package cargo_test 2 | 3 | import ( 4 | "fmt" 5 | "io" 6 | "net/http" 7 | "net/http/httptest" 8 | "os" 9 | "path/filepath" 10 | "testing" 11 | 12 | "github.com/paketo-buildpacks/packit/v2/cargo" 13 | "github.com/sclevine/spec" 14 | 15 | . "github.com/onsi/gomega" 16 | ) 17 | 18 | func testTransport(t *testing.T, context spec.G, it spec.S) { 19 | var Expect = NewWithT(t).Expect 20 | 21 | context("Drop", func() { 22 | var transport cargo.Transport 23 | 24 | it.Before(func() { 25 | transport = cargo.NewTransport() 26 | }) 27 | 28 | context("when the given uri is online", func() { 29 | var server *httptest.Server 30 | 31 | it.Before(func() { 32 | server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { 33 | switch req.URL.Path { 34 | case "/some-bundle": 35 | fmt.Fprint(w, "some-bundle-contents") 36 | default: 37 | http.NotFound(w, req) 38 | } 39 | })) 40 | }) 41 | 42 | it.After(func() { 43 | server.Close() 44 | }) 45 | 46 | it("downloads the file from a URI", func() { 47 | bundle, err := transport.Drop("", fmt.Sprintf("%s/some-bundle", server.URL)) 48 | Expect(err).NotTo(HaveOccurred()) 49 | 50 | contents, err := io.ReadAll(bundle) 51 | Expect(err).NotTo(HaveOccurred()) 52 | Expect(string(contents)).To(Equal("some-bundle-contents")) 53 | 54 | Expect(bundle.Close()).To(Succeed()) 55 | }) 56 | 57 | context("failure cases", func() { 58 | context("when the uri is malformed", func() { 59 | it("returns an error", func() { 60 | _, err := transport.Drop("", "%%%%") 61 | Expect(err).To(MatchError(ContainSubstring("failed to parse request uri"))) 62 | Expect(err).To(MatchError(ContainSubstring("invalid URL escape"))) 63 | }) 64 | }) 65 | 66 | context("when the request fails", func() { 67 | it.Before(func() { 68 | server.Close() 69 | }) 70 | 71 | it("returns an error", func() { 72 | _, err := transport.Drop("", fmt.Sprintf("%s/some-bundle", server.URL)) 73 | Expect(err).To(MatchError(ContainSubstring("failed to make request"))) 74 | Expect(err).To(MatchError(ContainSubstring("connection refused"))) 75 | }) 76 | }) 77 | 78 | context("when the http status indicates an error", func() { 79 | it("returns an error", func() { 80 | _, err := transport.Drop("", fmt.Sprintf("%s/some-bundle-that-does-not-exist", server.URL)) 81 | Expect(err).To(MatchError(ContainSubstring("unexpected status code 404 while fetching"))) 82 | }) 83 | }) 84 | }) 85 | }) 86 | 87 | context("when the uri is for a file", func() { 88 | var ( 89 | path string 90 | dir string 91 | ) 92 | 93 | it.Before(func() { 94 | var err error 95 | dir, err = os.MkdirTemp("", "bundle") 96 | Expect(err).NotTo(HaveOccurred()) 97 | 98 | path = "some-file" 99 | 100 | err = os.WriteFile(filepath.Join(dir, path), []byte("some-bundle-contents"), 0644) 101 | Expect(err).NotTo(HaveOccurred()) 102 | }) 103 | 104 | it.After(func() { 105 | Expect(os.RemoveAll(dir)).To(Succeed()) 106 | }) 107 | 108 | it("returns the file descriptor", func() { 109 | bundle, err := transport.Drop(dir, fmt.Sprintf("file://%s", path)) 110 | Expect(err).NotTo(HaveOccurred()) 111 | 112 | contents, err := io.ReadAll(bundle) 113 | Expect(err).NotTo(HaveOccurred()) 114 | Expect(string(contents)).To(Equal("some-bundle-contents")) 115 | 116 | Expect(bundle.Close()).To(Succeed()) 117 | }) 118 | 119 | context("failure cases", func() { 120 | it.Before(func() { 121 | Expect(os.RemoveAll(dir)).To(Succeed()) 122 | }) 123 | 124 | context("when the file does not exist", func() { 125 | it("returns an error", func() { 126 | _, err := transport.Drop(dir, fmt.Sprintf("file://%s", path)) 127 | Expect(err).To(MatchError(ContainSubstring("failed to open file"))) 128 | Expect(err).To(MatchError(ContainSubstring("no such file or directory"))) 129 | }) 130 | }) 131 | }) 132 | }) 133 | }) 134 | } 135 | -------------------------------------------------------------------------------- /cargo/validated_reader.go: -------------------------------------------------------------------------------- 1 | package cargo 2 | 3 | import ( 4 | "bytes" 5 | "crypto/sha256" 6 | "crypto/sha512" 7 | "encoding/hex" 8 | "errors" 9 | "fmt" 10 | "hash" 11 | "io" 12 | ) 13 | 14 | var ChecksumValidationError = errors.New("validation error: checksum does not match") 15 | 16 | type ValidatedReader struct { 17 | reader io.Reader 18 | checksum Checksum 19 | hash hash.Hash 20 | } 21 | 22 | type errorHash struct { 23 | hash.Hash 24 | 25 | err error 26 | } 27 | 28 | func NewValidatedReader(reader io.Reader, sum string) ValidatedReader { 29 | var hash hash.Hash 30 | checksum := Checksum(sum) 31 | 32 | switch checksum.Algorithm() { 33 | case "sha256": 34 | hash = sha256.New() 35 | case "sha512": 36 | hash = sha512.New() 37 | default: 38 | return ValidatedReader{hash: errorHash{err: fmt.Errorf("unsupported algorithm %q: the following algorithms are supported [sha256, sha512]", checksum.Algorithm())}} 39 | } 40 | 41 | return ValidatedReader{ 42 | reader: reader, 43 | checksum: checksum, 44 | hash: hash, 45 | } 46 | } 47 | 48 | func (vr ValidatedReader) Read(p []byte) (int, error) { 49 | if errHash, ok := vr.hash.(errorHash); ok { 50 | return 0, errHash.err 51 | } 52 | 53 | var done bool 54 | n, err := vr.reader.Read(p) 55 | if err != nil { 56 | if err == io.EOF { 57 | done = true 58 | } else { 59 | return n, err 60 | } 61 | } 62 | 63 | buffer := bytes.NewBuffer(p) 64 | _, err = io.CopyN(vr.hash, buffer, int64(n)) 65 | if err != nil { 66 | return n, err 67 | } 68 | 69 | if done { 70 | sum := hex.EncodeToString(vr.hash.Sum(nil)) 71 | if sum != vr.checksum.Hash() { 72 | return n, ChecksumValidationError 73 | } 74 | 75 | return n, io.EOF 76 | } 77 | 78 | return n, nil 79 | } 80 | 81 | func (vr ValidatedReader) Valid() (bool, error) { 82 | _, err := io.Copy(io.Discard, vr) 83 | if err != nil { 84 | if err == ChecksumValidationError { 85 | return false, nil 86 | } 87 | 88 | return false, err 89 | } 90 | 91 | return true, nil 92 | } 93 | -------------------------------------------------------------------------------- /cargo/validated_reader_test.go: -------------------------------------------------------------------------------- 1 | package cargo_test 2 | 3 | import ( 4 | "bytes" 5 | "io" 6 | "strings" 7 | "testing" 8 | 9 | "github.com/paketo-buildpacks/packit/v2/cargo" 10 | "github.com/sclevine/spec" 11 | 12 | . "github.com/onsi/gomega" 13 | ) 14 | 15 | func testValidatedReader(t *testing.T, context spec.G, it spec.S) { 16 | var ( 17 | Expect = NewWithT(t).Expect 18 | ) 19 | 20 | context("Read", func() { 21 | var buffer *bytes.Buffer 22 | it.Before(func() { 23 | buffer = bytes.NewBuffer(nil) 24 | }) 25 | 26 | it("reads the contents of the internal reader", func() { 27 | vr := cargo.NewValidatedReader(strings.NewReader("some-contents"), "sha256:6e32ea34db1b3755d7dec972eb72c705338f0dd8e0be881d966963438fb2e800") 28 | 29 | _, err := io.Copy(buffer, vr) 30 | Expect(err).NotTo(HaveOccurred()) 31 | Expect(buffer.String()).To(Equal("some-contents")) 32 | }) 33 | 34 | context("when running with a different algorithm", func() { 35 | it("reads the contents of the internal reader", func() { 36 | vr := cargo.NewValidatedReader(strings.NewReader("some-contents"), "sha512:b7b2b9e0a4d7f84985a720d1273166bb00132a60ac45388a7d3090a7d4c9692f38d019f807a02750f810f52c623362f977040231c2bbf5947170fe83686cfd9d") 37 | 38 | _, err := io.Copy(buffer, vr) 39 | Expect(err).NotTo(HaveOccurred()) 40 | Expect(buffer.String()).To(Equal("some-contents")) 41 | }) 42 | }) 43 | 44 | context("when the checksum does not match", func() { 45 | it("returns an error", func() { 46 | vr := cargo.NewValidatedReader(strings.NewReader("some-contents"), "sha256:this checksum does not match") 47 | 48 | _, err := io.Copy(buffer, vr) 49 | Expect(err).To(MatchError("validation error: checksum does not match")) 50 | }) 51 | }) 52 | 53 | context("when the internal reader cannot be read", func() { 54 | it("returns an error", func() { 55 | vr := cargo.NewValidatedReader(errorReader{}, "sha256:6e32ea34db1b3755d7dec972eb72c705338f0dd8e0be881d966963438fb2e800") 56 | 57 | _, err := io.Copy(buffer, vr) 58 | Expect(err).To(MatchError("failed to read")) 59 | }) 60 | }) 61 | 62 | context("failure cases", func() { 63 | context("there is an unsupported algorithm", func() { 64 | it("returns an error", func() { 65 | vr := cargo.NewValidatedReader(strings.NewReader("some-contents"), "magic:6e32ea34db1b3755d7dec972eb72c705338f0dd8e0be881d966963438fb2e800") 66 | 67 | _, err := io.Copy(buffer, vr) 68 | Expect(err).To(MatchError(`unsupported algorithm "magic": the following algorithms are supported [sha256, sha512]`)) 69 | }) 70 | }) 71 | }) 72 | }) 73 | 74 | context("Valid", func() { 75 | context("when the checksums match", func() { 76 | it("returns true", func() { 77 | vr := cargo.NewValidatedReader(strings.NewReader("some-contents"), "sha256:6e32ea34db1b3755d7dec972eb72c705338f0dd8e0be881d966963438fb2e800") 78 | 79 | ok, err := vr.Valid() 80 | Expect(err).NotTo(HaveOccurred()) 81 | Expect(ok).To(BeTrue()) 82 | }) 83 | }) 84 | 85 | context("when the checksums do not match", func() { 86 | it("returns false", func() { 87 | vr := cargo.NewValidatedReader(strings.NewReader("some-contents"), "sha256:this checksum does not match") 88 | 89 | ok, err := vr.Valid() 90 | Expect(err).NotTo(HaveOccurred()) 91 | Expect(ok).To(BeFalse()) 92 | }) 93 | }) 94 | 95 | context("failure cases", func() { 96 | context("when the internal reader cannot be read", func() { 97 | it("returns an error", func() { 98 | vr := cargo.NewValidatedReader(errorReader{}, "sha256:6e32ea34db1b3755d7dec972eb72c705338f0dd8e0be881d966963438fb2e800") 99 | 100 | ok, err := vr.Valid() 101 | Expect(err).To(MatchError("failed to read")) 102 | Expect(ok).To(BeFalse()) 103 | }) 104 | }) 105 | }) 106 | }) 107 | } 108 | -------------------------------------------------------------------------------- /chronos/clock.go: -------------------------------------------------------------------------------- 1 | package chronos 2 | 3 | import "time" 4 | 5 | var DefaultClock = NewClock(time.Now) 6 | 7 | type Clock struct { 8 | now func() time.Time 9 | } 10 | 11 | func NewClock(now func() time.Time) Clock { 12 | return Clock{now: now} 13 | } 14 | 15 | func (c Clock) Now() time.Time { 16 | return c.now() 17 | } 18 | 19 | func (c Clock) Measure(f func() error) (time.Duration, error) { 20 | then := c.Now() 21 | err := f() 22 | return c.Now().Sub(then), err 23 | } 24 | -------------------------------------------------------------------------------- /chronos/clock_test.go: -------------------------------------------------------------------------------- 1 | package chronos_test 2 | 3 | import ( 4 | "errors" 5 | "testing" 6 | "time" 7 | 8 | "github.com/paketo-buildpacks/packit/v2/chronos" 9 | "github.com/sclevine/spec" 10 | 11 | . "github.com/onsi/gomega" 12 | ) 13 | 14 | func testClock(t *testing.T, context spec.G, it spec.S) { 15 | var Expect = NewWithT(t).Expect 16 | 17 | context("Now", func() { 18 | it("returns the value from the given Now function", func() { 19 | now := time.Now() 20 | 21 | clock := chronos.NewClock(func() time.Time { 22 | return now 23 | }) 24 | 25 | Expect(clock.Now()).To(Equal(now)) 26 | }) 27 | }) 28 | 29 | context("Measure", func() { 30 | var clock chronos.Clock 31 | 32 | it.Before(func() { 33 | now := time.Now() 34 | times := []time.Time{now, now.Add(20 * time.Second)} 35 | 36 | clock = chronos.NewClock(func() time.Time { 37 | t := time.Now() 38 | 39 | if len(times) > 0 { 40 | t = times[0] 41 | times = times[1:] 42 | } 43 | 44 | return t 45 | }) 46 | }) 47 | 48 | it("returns the duration taken to perform the operation", func() { 49 | duration, err := clock.Measure(func() error { 50 | return nil 51 | }) 52 | Expect(err).NotTo(HaveOccurred()) 53 | Expect(duration).To(Equal(20 * time.Second)) 54 | }) 55 | 56 | context("when the operation errors", func() { 57 | it("returns that error", func() { 58 | _, err := clock.Measure(func() error { 59 | return errors.New("operation failed") 60 | }) 61 | Expect(err).To(MatchError("operation failed")) 62 | }) 63 | }) 64 | }) 65 | } 66 | -------------------------------------------------------------------------------- /chronos/doc.go: -------------------------------------------------------------------------------- 1 | // Package chronos provides clock functionality that can be useful when 2 | // developing and testing Cloud Native Buildpacks. 3 | // 4 | // Below is an example showing how you might use a Clock to measure the 5 | // duration of an operation: 6 | // 7 | // package main 8 | // 9 | // import ( 10 | // "os" 11 | // 12 | // "github.com/paketo-buildpacks/packit/v2/chronos" 13 | // ) 14 | // 15 | // func main() { 16 | // duration, err := chronos.DefaultClock.Measure(func() error { 17 | // // Perform some operation, like sleep for 10 seconds 18 | // time.Sleep(10 * time.Second) 19 | // 20 | // return nil 21 | // }) 22 | // if err != nil { 23 | // panic(err) 24 | // } 25 | // 26 | // fmt.Printf("duration: %s", duration) 27 | // // Output: duration: 10s 28 | // } 29 | // 30 | package chronos 31 | -------------------------------------------------------------------------------- /chronos/init_test.go: -------------------------------------------------------------------------------- 1 | package chronos_test 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/sclevine/spec" 7 | "github.com/sclevine/spec/report" 8 | ) 9 | 10 | func TestUnitChronos(t *testing.T) { 11 | suite := spec.New("packit/chronos", spec.Report(report.Terminal{})) 12 | suite("Clock", testClock) 13 | suite.Run(t) 14 | } 15 | -------------------------------------------------------------------------------- /draft/example_test.go: -------------------------------------------------------------------------------- 1 | package draft_test 2 | 3 | import ( 4 | "fmt" 5 | "regexp" 6 | 7 | "github.com/paketo-buildpacks/packit/v2" 8 | "github.com/paketo-buildpacks/packit/v2/draft" 9 | ) 10 | 11 | func ExamplePlanner_Resolve() { 12 | buildpackPlanEntries := []packit.BuildpackPlanEntry{ 13 | { 14 | Name: "fred", 15 | }, 16 | { 17 | Name: "clint", 18 | Metadata: map[string]interface{}{ 19 | "version-source": "high", 20 | }, 21 | }, 22 | { 23 | Name: "fred", 24 | Metadata: map[string]interface{}{ 25 | "version-source": "high", 26 | }, 27 | }, 28 | { 29 | Name: "fred", 30 | Metadata: map[string]interface{}{ 31 | "version-source": "some-low-priority", 32 | }, 33 | }, 34 | } 35 | 36 | priorities := []interface{}{"high", regexp.MustCompile(`.*low.*`)} 37 | 38 | planner := draft.NewPlanner() 39 | 40 | entry, entries := planner.Resolve("fred", buildpackPlanEntries, priorities) 41 | 42 | printEntry := func(e packit.BuildpackPlanEntry) { 43 | var source string 44 | source, ok := e.Metadata["version-source"].(string) 45 | if !ok { 46 | source = "" 47 | } 48 | fmt.Printf("%s => %q\n", e.Name, source) 49 | } 50 | 51 | fmt.Println("Highest Priority Entry") 52 | printEntry(entry) 53 | fmt.Println("Buildpack Plan Entry List Priority Sorted") 54 | for _, e := range entries { 55 | printEntry(e) 56 | } 57 | 58 | // Output: 59 | // Highest Priority Entry 60 | // fred => "high" 61 | // Buildpack Plan Entry List Priority Sorted 62 | // fred => "high" 63 | // fred => "some-low-priority" 64 | // fred => "" 65 | } 66 | 67 | func ExamplePlanner_MergeLayerTypes() { 68 | buildpackPlanEntries := []packit.BuildpackPlanEntry{ 69 | { 70 | Name: "fred", 71 | }, 72 | { 73 | Name: "clint", 74 | Metadata: map[string]interface{}{ 75 | "build": false, 76 | }, 77 | }, 78 | { 79 | Name: "fred", 80 | Metadata: map[string]interface{}{ 81 | "build": true, 82 | }, 83 | }, 84 | { 85 | Name: "fred", 86 | Metadata: map[string]interface{}{ 87 | "launch": true, 88 | }, 89 | }, 90 | } 91 | 92 | planner := draft.NewPlanner() 93 | 94 | launch, build := planner.MergeLayerTypes("fred", buildpackPlanEntries) 95 | 96 | fmt.Printf("launch => %t; build => %t", launch, build) 97 | 98 | // Output: 99 | // launch => true; build => true 100 | } 101 | -------------------------------------------------------------------------------- /draft/init_test.go: -------------------------------------------------------------------------------- 1 | package draft_test 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/sclevine/spec" 7 | "github.com/sclevine/spec/report" 8 | ) 9 | 10 | func TestUnitDraft(t *testing.T) { 11 | suite := spec.New("packit/draft", spec.Report(report.Terminal{})) 12 | suite("Planner", testPlanner) 13 | suite.Run(t) 14 | } 15 | -------------------------------------------------------------------------------- /draft/planner.go: -------------------------------------------------------------------------------- 1 | // Package draft provides a service for resolving the priority of buildpack 2 | // plan entries as well as consilidating build and launch requirements. 3 | package draft 4 | 5 | import ( 6 | "reflect" 7 | "regexp" 8 | "sort" 9 | 10 | "github.com/paketo-buildpacks/packit/v2" 11 | ) 12 | 13 | // A Planner sorts buildpack plan entries using a given list of priorities. A 14 | // Planner can also give the OR merged state of launch and build fields that 15 | // are defined in the buildpack plan entries metadata field. 16 | type Planner struct { 17 | } 18 | 19 | // NewPlanner returns a new Planner object. 20 | func NewPlanner() Planner { 21 | return Planner{} 22 | } 23 | 24 | // Resolve takes the name of buildpack plan entries that you want to sort, the 25 | // buildpack plan entries that you want to be sorted, and a priority list of 26 | // version-sources where the 0th index is the highest priority. Priorities can 27 | // either be a string, in which case an exact string match with the 28 | // version-source wil be required, or it can be a regular expression. It 29 | // returns the highest priority entry as well as the sorted and filtered list 30 | // of buildpack plan entries that were given. Entries with no given 31 | // version-source are the lowest priority. 32 | // 33 | // If nil is passed for the value of the priority list then the function will 34 | // just return the first filtered entry from the list of the entries that were 35 | // passed into the function initially. 36 | func (p Planner) Resolve(name string, entries []packit.BuildpackPlanEntry, priorities []interface{}) (packit.BuildpackPlanEntry, []packit.BuildpackPlanEntry) { 37 | var filteredEntries []packit.BuildpackPlanEntry 38 | for _, e := range entries { 39 | if e.Name == name { 40 | filteredEntries = append(filteredEntries, e) 41 | } 42 | } 43 | 44 | if len(filteredEntries) == 0 { 45 | return packit.BuildpackPlanEntry{}, nil 46 | } 47 | 48 | sort.Slice(filteredEntries, func(i, j int) bool { 49 | leftSource := filteredEntries[i].Metadata["version-source"] 50 | left, _ := leftSource.(string) 51 | leftPriority := -1 52 | 53 | rightSource := filteredEntries[j].Metadata["version-source"] 54 | right, _ := rightSource.(string) 55 | rightPriority := -1 56 | 57 | for index, match := range priorities { 58 | if r, ok := match.(*regexp.Regexp); ok { 59 | if r.MatchString(left) { 60 | leftPriority = len(priorities) - index - 1 61 | } 62 | } else { 63 | if reflect.DeepEqual(match, left) { 64 | leftPriority = len(priorities) - index - 1 65 | } 66 | } 67 | 68 | if r, ok := match.(*regexp.Regexp); ok { 69 | if r.MatchString(right) { 70 | rightPriority = len(priorities) - index - 1 71 | } 72 | } else { 73 | if reflect.DeepEqual(match, right) { 74 | rightPriority = len(priorities) - index - 1 75 | } 76 | } 77 | } 78 | 79 | return leftPriority > rightPriority 80 | }) 81 | 82 | return filteredEntries[0], filteredEntries 83 | } 84 | 85 | // MergeLayerTypes takes the name of buildpack plan entries that you want and 86 | // the list buildpack plan entries you want merged layered types from. It 87 | // returns the OR result of the launch and build keys for all of the buildpack 88 | // plan entries with the specified name. The first return is the value of the 89 | // OR launch the second return value is OR build. 90 | func (p Planner) MergeLayerTypes(name string, entries []packit.BuildpackPlanEntry) (bool, bool) { 91 | var launch, build bool 92 | for _, e := range entries { 93 | if e.Name == name { 94 | for _, phase := range []string{"build", "launch"} { 95 | if e.Metadata[phase] == true { 96 | switch phase { 97 | case "build": 98 | build = true 99 | case "launch": 100 | launch = true 101 | } 102 | } 103 | } 104 | } 105 | } 106 | 107 | return launch, build 108 | } 109 | -------------------------------------------------------------------------------- /environment.go: -------------------------------------------------------------------------------- 1 | package packit 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "path/filepath" 7 | ) 8 | 9 | // Environment provides a key-value store for declaring environment variables. 10 | type Environment map[string]string 11 | 12 | // Append adds a key-value pair to the environment as an appended value 13 | // according to the specification: 14 | // https://github.com/buildpacks/spec/blob/main/buildpack.md#append. 15 | func (e Environment) Append(name, value, delim string) { 16 | e[name+".append"] = value 17 | 18 | delete(e, name+".delim") 19 | if delim != "" { 20 | e[name+".delim"] = delim 21 | } 22 | } 23 | 24 | // Default adds a key-value pair to the environment as a default value 25 | // according to the specification: 26 | // https://github.com/buildpacks/spec/blob/main/buildpack.md#default. 27 | func (e Environment) Default(name, value string) { 28 | e[name+".default"] = value 29 | } 30 | 31 | // Override adds a key-value pair to the environment as an overridden value 32 | // according to the specification: 33 | // https://github.com/buildpacks/spec/blob/main/buildpack.md#override. 34 | func (e Environment) Override(name, value string) { 35 | e[name+".override"] = value 36 | } 37 | 38 | // Prepend adds a key-value pair to the environment as a prepended value 39 | // according to the specification: 40 | // https://github.com/buildpacks/spec/blob/main/buildpack.md#prepend. 41 | func (e Environment) Prepend(name, value, delim string) { 42 | e[name+".prepend"] = value 43 | 44 | delete(e, name+".delim") 45 | if delim != "" { 46 | e[name+".delim"] = delim 47 | } 48 | } 49 | 50 | func newEnvironmentFromPath(path string) (Environment, error) { 51 | envFiles, err := filepath.Glob(filepath.Join(path, "*")) 52 | if err != nil { 53 | return Environment{}, fmt.Errorf("failed to match env directory files: %s", err) 54 | } 55 | 56 | environment := Environment{} 57 | for _, file := range envFiles { 58 | switch filepath.Ext(file) { 59 | case ".delim", ".prepend", ".append", ".default", ".override": 60 | contents, err := os.ReadFile(file) 61 | if err != nil { 62 | return Environment{}, fmt.Errorf("failed to load environment variable: %s", err) 63 | } 64 | 65 | environment[filepath.Base(file)] = string(contents) 66 | } 67 | } 68 | 69 | return environment, nil 70 | } 71 | -------------------------------------------------------------------------------- /fail.go: -------------------------------------------------------------------------------- 1 | package packit 2 | 3 | import "github.com/paketo-buildpacks/packit/v2/internal" 4 | 5 | // Fail is a sentinal value that can be used to indicate a failure to detect 6 | // during the detect phase. Fail implements the Error interface and should be 7 | // returned as the error value in the DetectFunc signature. Fail also supports 8 | // a modifier function, WithMessage, that allows the caller to set a custom 9 | // failure message. The WithMessage function supports a fmt.Printf-like format 10 | // string and variadic arguments to build a message, eg: 11 | // packit.Fail.WithMessage("failed: %w", err). 12 | var Fail = internal.Fail 13 | -------------------------------------------------------------------------------- /fakes/exit_handler.go: -------------------------------------------------------------------------------- 1 | package fakes 2 | 3 | import "sync" 4 | 5 | type ExitHandler struct { 6 | ErrorCall struct { 7 | mutex sync.Mutex 8 | CallCount int 9 | Receives struct { 10 | Error error 11 | } 12 | Stub func(error) 13 | } 14 | } 15 | 16 | func (f *ExitHandler) Error(param1 error) { 17 | f.ErrorCall.mutex.Lock() 18 | defer f.ErrorCall.mutex.Unlock() 19 | f.ErrorCall.CallCount++ 20 | f.ErrorCall.Receives.Error = param1 21 | if f.ErrorCall.Stub != nil { 22 | f.ErrorCall.Stub(param1) 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /fakes/some-executable/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "io" 6 | "os" 7 | ) 8 | 9 | var fail string 10 | 11 | func main() { 12 | fmt.Fprintf(os.Stdout, "Output on stdout\n") 13 | fmt.Fprintf(os.Stderr, "Output on stderr\n") 14 | fmt.Printf("Arguments: %v\n", os.Args) 15 | 16 | stdin, err := io.ReadAll(os.Stdin) 17 | if err != nil { 18 | fmt.Println(err) 19 | os.Exit(1) 20 | } 21 | 22 | fmt.Printf("Input on stdin\n%s\n", stdin) 23 | 24 | pwd, _ := os.Getwd() 25 | fmt.Printf("PWD=%s\n", pwd) 26 | 27 | for _, env := range os.Environ() { 28 | fmt.Printf("%s\n", env) 29 | } 30 | 31 | if fail == "true" { 32 | fmt.Fprintf(os.Stdout, "Error on stdout\n") 33 | fmt.Fprintf(os.Stderr, "Error on stderr\n") 34 | os.Exit(1) 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /fs/checksum_calculator.go: -------------------------------------------------------------------------------- 1 | package fs 2 | 3 | import ( 4 | "crypto/sha256" 5 | "encoding/hex" 6 | "fmt" 7 | "io" 8 | "os" 9 | "path/filepath" 10 | "runtime" 11 | "sort" 12 | ) 13 | 14 | // ChecksumCalculator can be used to calculate the SHA256 checksum of a given file or 15 | // directory. When given a directory, checksum calculation will be performed in 16 | // parallel. 17 | type ChecksumCalculator struct{} 18 | 19 | // NewChecksumCalculator returns a new instance of a ChecksumCalculator. 20 | func NewChecksumCalculator() ChecksumCalculator { 21 | return ChecksumCalculator{} 22 | } 23 | 24 | type calculatedFile struct { 25 | path string 26 | checksum []byte 27 | err error 28 | } 29 | 30 | // Sum returns a hex-encoded SHA256 checksum value of a file or directory given a path. 31 | func (c ChecksumCalculator) Sum(paths ...string) (string, error) { 32 | var files []string 33 | for _, path := range paths { 34 | err := filepath.Walk(path, func(path string, info os.FileInfo, err error) error { 35 | if err != nil { 36 | return err 37 | } 38 | 39 | if info.Mode().IsRegular() { 40 | files = append(files, path) 41 | } 42 | 43 | return nil 44 | }) 45 | if err != nil { 46 | return "", fmt.Errorf("failed to calculate checksum: %w", err) 47 | } 48 | } 49 | 50 | //Gather all checksums 51 | var sums [][]byte 52 | for _, f := range getParallelChecksums(files) { 53 | if f.err != nil { 54 | return "", fmt.Errorf("failed to calculate checksum: %w", f.err) 55 | } 56 | 57 | sums = append(sums, f.checksum) 58 | } 59 | 60 | if len(sums) == 1 { 61 | return hex.EncodeToString(sums[0]), nil 62 | } 63 | 64 | hash := sha256.New() 65 | for _, sum := range sums { 66 | _, err := hash.Write(sum) 67 | if err != nil { 68 | return "", fmt.Errorf("failed to calculate checksum: %w", err) 69 | } 70 | } 71 | return hex.EncodeToString(hash.Sum(nil)), nil 72 | } 73 | 74 | func getParallelChecksums(filesFromDir []string) []calculatedFile { 75 | var checksumResults []calculatedFile 76 | numFiles := len(filesFromDir) 77 | files := make(chan string, numFiles) 78 | calculatedFiles := make(chan calculatedFile, numFiles) 79 | 80 | //Spawns workers 81 | for i := 0; i < runtime.NumCPU(); i++ { 82 | go fileChecksumer(files, calculatedFiles) 83 | } 84 | 85 | //Puts files in worker queue 86 | for _, f := range filesFromDir { 87 | files <- f 88 | } 89 | 90 | close(files) 91 | 92 | //Pull all calculated files off of result queue 93 | for i := 0; i < numFiles; i++ { 94 | checksumResults = append(checksumResults, <-calculatedFiles) 95 | } 96 | 97 | //Sort calculated files for consistent checksuming 98 | sort.Slice(checksumResults, func(i, j int) bool { 99 | return checksumResults[i].path < checksumResults[j].path 100 | }) 101 | 102 | return checksumResults 103 | } 104 | 105 | func fileChecksumer(files chan string, calculatedFiles chan calculatedFile) { 106 | for path := range files { 107 | result := calculatedFile{path: path} 108 | 109 | file, err := os.Open(path) 110 | if err != nil { 111 | result.err = err 112 | calculatedFiles <- result 113 | continue 114 | } 115 | 116 | hash := sha256.New() 117 | _, err = io.Copy(hash, file) 118 | if err != nil { 119 | result.err = err 120 | calculatedFiles <- result 121 | continue 122 | } 123 | 124 | if err := file.Close(); err != nil { 125 | result.err = err 126 | calculatedFiles <- result 127 | continue 128 | } 129 | 130 | result.checksum = hash.Sum(nil) 131 | calculatedFiles <- result 132 | } 133 | } 134 | -------------------------------------------------------------------------------- /fs/copy.go: -------------------------------------------------------------------------------- 1 | package fs 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "io" 7 | "os" 8 | "path/filepath" 9 | ) 10 | 11 | // Copy will move a source file or directory to a destination. For directories, 12 | // move will remap relative symlinks ensuring that they align with the 13 | // destination directory. If the destination exists prior to invocation, it 14 | // will be removed. 15 | func Copy(source, destination string) error { 16 | err := os.Remove(destination) 17 | if err != nil { 18 | if !errors.Is(err, os.ErrNotExist) { 19 | return fmt.Errorf("failed to copy: destination exists: %w", err) 20 | } 21 | } 22 | 23 | info, err := os.Stat(source) 24 | if err != nil { 25 | return err 26 | } 27 | 28 | if info.IsDir() { 29 | err = copyDirectory(source, destination) 30 | if err != nil { 31 | return err 32 | } 33 | } else { 34 | err = copyFile(source, destination) 35 | if err != nil { 36 | return err 37 | } 38 | } 39 | 40 | return nil 41 | } 42 | 43 | func copyFile(source, destination string) error { 44 | sourceFile, err := os.Open(source) 45 | if err != nil { 46 | return err 47 | } 48 | defer sourceFile.Close() 49 | 50 | destinationFile, err := os.Create(destination) 51 | if err != nil { 52 | return err 53 | } 54 | defer destinationFile.Close() 55 | 56 | _, err = io.Copy(destinationFile, sourceFile) 57 | if err != nil { 58 | return err 59 | } 60 | 61 | info, err := sourceFile.Stat() 62 | if err != nil { 63 | return err 64 | } 65 | 66 | err = os.Chmod(destination, info.Mode()) 67 | if err != nil { 68 | return err 69 | } 70 | 71 | return nil 72 | } 73 | 74 | func copyDirectory(source, destination string) error { 75 | err := filepath.Walk(source, func(path string, info os.FileInfo, err error) error { 76 | if err != nil { 77 | return err 78 | } 79 | 80 | path, err = filepath.Rel(source, path) 81 | if err != nil { 82 | return err 83 | } 84 | 85 | switch { 86 | case info.IsDir(): 87 | err = os.Mkdir(filepath.Join(destination, path), os.ModePerm) 88 | if err != nil { 89 | return err 90 | } 91 | 92 | case (info.Mode() & os.ModeSymlink) != 0: 93 | err = copyLink(source, destination, path) 94 | if err != nil { 95 | return err 96 | } 97 | 98 | default: 99 | err = copyFile(filepath.Join(source, path), filepath.Join(destination, path)) 100 | if err != nil { 101 | return err 102 | } 103 | } 104 | 105 | return nil 106 | }) 107 | 108 | if err != nil { 109 | return err 110 | } 111 | 112 | return nil 113 | } 114 | 115 | func copyLink(source, destination, path string) error { 116 | link, err := os.Readlink(filepath.Join(source, path)) 117 | if err != nil { 118 | return err 119 | } 120 | 121 | err = os.Symlink(link, filepath.Join(destination, path)) 122 | if err != nil { 123 | return err 124 | } 125 | 126 | return nil 127 | } 128 | -------------------------------------------------------------------------------- /fs/doc.go: -------------------------------------------------------------------------------- 1 | // Package fs provides a set of filesystem helpers that can be useful when 2 | // developing Cloud Native Buildpacks. 3 | package fs 4 | -------------------------------------------------------------------------------- /fs/exists.go: -------------------------------------------------------------------------------- 1 | package fs 2 | 3 | import ( 4 | "errors" 5 | "os" 6 | ) 7 | 8 | // Exists returns true if a file or directory at the given path is present and false otherwise. 9 | func Exists(path string) (bool, error) { 10 | _, err := os.Stat(path) 11 | if err != nil { 12 | if errors.Is(err, os.ErrNotExist) { 13 | return false, nil 14 | } 15 | return false, err 16 | } 17 | return true, nil 18 | } 19 | -------------------------------------------------------------------------------- /fs/exists_test.go: -------------------------------------------------------------------------------- 1 | package fs_test 2 | 3 | import ( 4 | "os" 5 | "path/filepath" 6 | "testing" 7 | 8 | "github.com/paketo-buildpacks/packit/v2/fs" 9 | "github.com/sclevine/spec" 10 | 11 | . "github.com/onsi/gomega" 12 | ) 13 | 14 | func testExists(t *testing.T, context spec.G, it spec.S) { 15 | var ( 16 | Expect = NewWithT(t).Expect 17 | 18 | dirPath string 19 | filePath string 20 | ) 21 | 22 | context("Exists", func() { 23 | it.Before(func() { 24 | var err error 25 | dirPath, err = os.MkdirTemp("", "dir") 26 | Expect(err).NotTo(HaveOccurred()) 27 | filePath = filepath.Join(dirPath, "some-file") 28 | }) 29 | 30 | it.After(func() { 31 | Expect(os.RemoveAll(dirPath)).To(Succeed()) 32 | }) 33 | 34 | context("when the file exists", func() { 35 | it.Before(func() { 36 | Expect(os.WriteFile(filePath, []byte("hello file"), 0644)).To(Succeed()) 37 | }) 38 | 39 | it("returns true", func() { 40 | Expect(fs.Exists(filePath)).To(BeTrue()) 41 | }) 42 | }) 43 | 44 | context("when the file DOES NOT exists", func() { 45 | it.Before(func() { 46 | Expect(os.RemoveAll(dirPath)).To(Succeed()) 47 | }) 48 | 49 | it("returns false", func() { 50 | Expect(fs.Exists(filePath)).To(BeFalse()) 51 | }) 52 | }) 53 | 54 | context("when the file cannot be read", func() { 55 | it.Before(func() { 56 | Expect(os.WriteFile(filePath, nil, 0644)).To(Succeed()) 57 | Expect(os.Chmod(dirPath, 0000)).To(Succeed()) 58 | }) 59 | 60 | it.After(func() { 61 | Expect(os.Chmod(dirPath, os.ModePerm)).To(Succeed()) 62 | }) 63 | 64 | it("returns false and an error", func() { 65 | exists, err := fs.Exists(filePath) 66 | Expect(err.Error()).To(ContainSubstring("permission denied")) 67 | Expect(exists).To(BeFalse()) 68 | }) 69 | }) 70 | }) 71 | } 72 | -------------------------------------------------------------------------------- /fs/init_test.go: -------------------------------------------------------------------------------- 1 | package fs_test 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/sclevine/spec" 7 | "github.com/sclevine/spec/report" 8 | ) 9 | 10 | func TestUnitFS(t *testing.T) { 11 | suite := spec.New("packit/fs", spec.Report(report.Terminal{})) 12 | suite("ChecksumCalculator", testChecksumCalculator) 13 | suite("Copy", testCopy) 14 | suite("Exists", testExists) 15 | suite("IsEmptyDir", testIsEmptyDir) 16 | suite("Move", testMove) 17 | suite.Run(t) 18 | } 19 | -------------------------------------------------------------------------------- /fs/is_empty_dir.go: -------------------------------------------------------------------------------- 1 | package fs 2 | 3 | import "os" 4 | 5 | // IsEmptyDir checks to see if a directory exists and is empty. 6 | func IsEmptyDir(path string) bool { 7 | contents, err := os.ReadDir(path) 8 | if err != nil { 9 | return false 10 | } 11 | 12 | return len(contents) == 0 13 | } 14 | -------------------------------------------------------------------------------- /fs/is_empty_dir_test.go: -------------------------------------------------------------------------------- 1 | package fs_test 2 | 3 | import ( 4 | "os" 5 | "path/filepath" 6 | "testing" 7 | 8 | "github.com/paketo-buildpacks/packit/v2/fs" 9 | "github.com/sclevine/spec" 10 | 11 | . "github.com/onsi/gomega" 12 | ) 13 | 14 | func testIsEmptyDir(t *testing.T, context spec.G, it spec.S) { 15 | var ( 16 | Expect = NewWithT(t).Expect 17 | 18 | path string 19 | ) 20 | 21 | it.Before(func() { 22 | var err error 23 | path, err = os.MkdirTemp("", "dir") 24 | Expect(err).NotTo(HaveOccurred()) 25 | }) 26 | 27 | it.After(func() { 28 | Expect(os.RemoveAll(path)).To(Succeed()) 29 | }) 30 | 31 | context("when the directory is empty", func() { 32 | it("returns true", func() { 33 | Expect(fs.IsEmptyDir(path)).To(BeTrue()) 34 | }) 35 | }) 36 | 37 | context("when the directory is not empty", func() { 38 | it.Before(func() { 39 | Expect(os.WriteFile(filepath.Join(path, "some-file"), []byte{}, 0644)).To(Succeed()) 40 | }) 41 | 42 | it("returns false", func() { 43 | Expect(fs.IsEmptyDir(path)).To(BeFalse()) 44 | }) 45 | }) 46 | 47 | context("when the directory does not exist", func() { 48 | it.Before(func() { 49 | Expect(os.RemoveAll(path)).To(Succeed()) 50 | }) 51 | 52 | it("returns false", func() { 53 | Expect(fs.IsEmptyDir(path)).To(BeFalse()) 54 | }) 55 | }) 56 | 57 | context("when the directory cannot be read", func() { 58 | it.Before(func() { 59 | Expect(os.Chmod(path, 0000)).To(Succeed()) 60 | }) 61 | 62 | it.After(func() { 63 | Expect(os.Chmod(path, os.ModePerm)).To(Succeed()) 64 | }) 65 | 66 | it("returns false", func() { 67 | Expect(fs.IsEmptyDir(path)).To(BeFalse()) 68 | }) 69 | }) 70 | } 71 | -------------------------------------------------------------------------------- /fs/move.go: -------------------------------------------------------------------------------- 1 | package fs 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | ) 7 | 8 | // Move will move a source file or directory to a destination. For directories, 9 | // move will remap relative symlinks ensuring that they align with the 10 | // destination directory. If the destination exists prior to invocation, it 11 | // will be removed. Additionally, the source will be removed once it has been 12 | // copied to the destination. 13 | func Move(source, destination string) error { 14 | err := Copy(source, destination) 15 | if err != nil { 16 | return fmt.Errorf("failed to move: %s", err) 17 | } 18 | 19 | err = os.RemoveAll(source) 20 | if err != nil { 21 | return err 22 | } 23 | 24 | return nil 25 | } 26 | -------------------------------------------------------------------------------- /init_test.go: -------------------------------------------------------------------------------- 1 | package packit_test 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/sclevine/spec" 7 | "github.com/sclevine/spec/report" 8 | ) 9 | 10 | func TestUnitPackit(t *testing.T) { 11 | suite := spec.New("packit", spec.Report(report.Terminal{})) 12 | suite("Build", testBuild) 13 | suite("Detect", testDetect) 14 | suite("Generate", testGenerate) 15 | suite("Environment", testEnvironment) 16 | suite("Layer", testLayer) 17 | suite("Layers", testLayers) 18 | suite("Run", testRun) 19 | suite.Run(t) 20 | } 21 | -------------------------------------------------------------------------------- /internal/environment_writer.go: -------------------------------------------------------------------------------- 1 | package internal 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "path/filepath" 7 | "regexp" 8 | "strings" 9 | ) 10 | 11 | type EnvironmentWriter struct{} 12 | 13 | func NewEnvironmentWriter() EnvironmentWriter { 14 | return EnvironmentWriter{} 15 | } 16 | 17 | func (w EnvironmentWriter) Write(dir string, env map[string]string) error { 18 | if len(env) == 0 { 19 | return nil 20 | } 21 | 22 | err := os.MkdirAll(dir, os.ModePerm) 23 | if err != nil { 24 | return err 25 | } 26 | 27 | // this regex checks that map keys contain valid env var name characters, 28 | // per https://pubs.opengroup.org/onlinepubs/9699919799/ 29 | validEnvVarRegex := regexp.MustCompile(`^[a-zA-Z_]{1,}[a-zA-Z0-9_]*$`) 30 | 31 | for key, value := range env { 32 | parts := strings.SplitN(key, ".", 2) 33 | if !validEnvVarRegex.MatchString(parts[0]) { 34 | return fmt.Errorf("invalid environment variable name '%s'", parts[0]) 35 | } 36 | err := os.WriteFile(filepath.Join(dir, key), []byte(value), 0644) 37 | if err != nil { 38 | return err 39 | } 40 | } 41 | 42 | return nil 43 | } 44 | -------------------------------------------------------------------------------- /internal/environment_writer_test.go: -------------------------------------------------------------------------------- 1 | package internal_test 2 | 3 | import ( 4 | "os" 5 | "path/filepath" 6 | "testing" 7 | 8 | "github.com/paketo-buildpacks/packit/v2/internal" 9 | "github.com/sclevine/spec" 10 | 11 | . "github.com/onsi/gomega" 12 | ) 13 | 14 | func testEnvironmentWriter(t *testing.T, context spec.G, it spec.S) { 15 | var ( 16 | Expect = NewWithT(t).Expect 17 | 18 | tmpDir string 19 | writer internal.EnvironmentWriter 20 | ) 21 | 22 | it.Before(func() { 23 | var err error 24 | tmpDir, err = os.MkdirTemp("", "env-vars") 25 | Expect(err).NotTo(HaveOccurred()) 26 | 27 | Expect(os.RemoveAll(tmpDir)).To(Succeed()) 28 | 29 | writer = internal.NewEnvironmentWriter() 30 | }) 31 | 32 | it.After(func() { 33 | Expect(os.RemoveAll(tmpDir)).To(Succeed()) 34 | }) 35 | 36 | it("writes the given environment to a directory", func() { 37 | err := writer.Write(tmpDir, map[string]string{ 38 | "some_name": "some-content", 39 | "OTHER_NAME": "other-content", 40 | "ANOTHER.override": "more-content", 41 | }) 42 | Expect(err).NotTo(HaveOccurred()) 43 | 44 | content, err := os.ReadFile(filepath.Join(tmpDir, "some_name")) 45 | Expect(err).NotTo(HaveOccurred()) 46 | Expect(string(content)).To(Equal("some-content")) 47 | 48 | content, err = os.ReadFile(filepath.Join(tmpDir, "OTHER_NAME")) 49 | Expect(err).NotTo(HaveOccurred()) 50 | Expect(string(content)).To(Equal("other-content")) 51 | 52 | content, err = os.ReadFile(filepath.Join(tmpDir, "ANOTHER.override")) 53 | Expect(err).NotTo(HaveOccurred()) 54 | Expect(string(content)).To(Equal("more-content")) 55 | }) 56 | 57 | it("writes does not create a directory of the env map is empty", func() { 58 | err := writer.Write(tmpDir, map[string]string{}) 59 | Expect(err).NotTo(HaveOccurred()) 60 | 61 | Expect(tmpDir).NotTo(BeAnExistingFile()) 62 | }) 63 | 64 | context("failure cases", func() { 65 | context("when the directory cannot be created", func() { 66 | it.Before(func() { 67 | Expect(os.MkdirAll(tmpDir, 0000)).To(Succeed()) 68 | }) 69 | 70 | it("returns an error", func() { 71 | err := writer.Write(filepath.Join(tmpDir, "sub-dir"), map[string]string{ 72 | "some_name": "some-content", 73 | "OTHER_NAME": "other-content", 74 | }) 75 | Expect(err).To(MatchError(ContainSubstring("permission denied"))) 76 | }) 77 | }) 78 | 79 | context("when the env file cannot be created", func() { 80 | it.Before(func() { 81 | Expect(os.MkdirAll(tmpDir, 0000)).To(Succeed()) 82 | }) 83 | 84 | it("returns an error", func() { 85 | err := writer.Write(tmpDir, map[string]string{ 86 | "some_name": "some-content", 87 | "OTHER_NAME": "other-content", 88 | }) 89 | Expect(err).To(MatchError(ContainSubstring("permission denied"))) 90 | }) 91 | }) 92 | 93 | context("when env var name is invalid", func() { 94 | it("returns an error", func() { 95 | err := writer.Write(tmpDir, map[string]string{ 96 | "INVA=*LID.override": "more-content", 97 | }) 98 | Expect(err).To(MatchError(ContainSubstring("invalid environment variable name 'INVA=*LID'"))) 99 | }) 100 | }) 101 | }) 102 | } 103 | -------------------------------------------------------------------------------- /internal/exit_handler.go: -------------------------------------------------------------------------------- 1 | package internal 2 | 3 | import ( 4 | "fmt" 5 | "io" 6 | "os" 7 | ) 8 | 9 | type Option func(handler ExitHandler) ExitHandler 10 | 11 | func WithExitHandlerStderr(stderr io.Writer) Option { 12 | return func(handler ExitHandler) ExitHandler { 13 | handler.stderr = stderr 14 | return handler 15 | } 16 | } 17 | 18 | func WithExitHandlerStdout(stdout io.Writer) Option { 19 | return func(handler ExitHandler) ExitHandler { 20 | handler.stdout = stdout 21 | return handler 22 | } 23 | } 24 | 25 | func WithExitHandlerExitFunc(e func(int)) Option { 26 | return func(handler ExitHandler) ExitHandler { 27 | handler.exitFunc = e 28 | return handler 29 | } 30 | } 31 | 32 | type ExitHandler struct { 33 | stdout io.Writer 34 | stderr io.Writer 35 | exitFunc func(int) 36 | } 37 | 38 | func NewExitHandler(options ...Option) ExitHandler { 39 | handler := ExitHandler{ 40 | stdout: os.Stdout, 41 | stderr: os.Stderr, 42 | exitFunc: os.Exit, 43 | } 44 | 45 | for _, option := range options { 46 | handler = option(handler) 47 | } 48 | 49 | return handler 50 | } 51 | 52 | func (h ExitHandler) Error(err error) { 53 | fmt.Fprintln(h.stderr, err) 54 | 55 | var code int 56 | switch err.(type) { 57 | case failError: 58 | code = 100 59 | case nil: 60 | code = 0 61 | default: 62 | code = 1 63 | } 64 | 65 | h.exitFunc(code) 66 | } 67 | -------------------------------------------------------------------------------- /internal/exit_handler_test.go: -------------------------------------------------------------------------------- 1 | package internal_test 2 | 3 | import ( 4 | "bytes" 5 | "errors" 6 | "testing" 7 | 8 | "github.com/paketo-buildpacks/packit/v2/internal" 9 | "github.com/sclevine/spec" 10 | 11 | . "github.com/onsi/gomega" 12 | ) 13 | 14 | func testExitHandler(t *testing.T, context spec.G, it spec.S) { 15 | var ( 16 | Expect = NewWithT(t).Expect 17 | 18 | exitCode int 19 | stderr *bytes.Buffer 20 | stdout *bytes.Buffer 21 | handler internal.ExitHandler 22 | ) 23 | 24 | it.Before(func() { 25 | stderr = bytes.NewBuffer([]byte{}) 26 | stdout = bytes.NewBuffer([]byte{}) 27 | 28 | handler = internal.NewExitHandler( 29 | internal.WithExitHandlerStderr(stderr), 30 | internal.WithExitHandlerStdout(stdout), 31 | internal.WithExitHandlerExitFunc(func(c int) { exitCode = c }), 32 | ) 33 | }) 34 | 35 | it("prints the error message and exits with the right error code", func() { 36 | handler.Error(errors.New("some-error-message")) 37 | Expect(stderr).To(ContainSubstring("some-error-message")) 38 | Expect(stdout.String()).To(BeEmpty()) 39 | }) 40 | 41 | context("when the error is nil", func() { 42 | it("exits with code 0", func() { 43 | handler.Error(nil) 44 | Expect(exitCode).To(Equal(0)) 45 | }) 46 | }) 47 | 48 | context("when the error is non-nil", func() { 49 | it("exits with code 1", func() { 50 | handler.Error(errors.New("failed")) 51 | Expect(exitCode).To(Equal(1)) 52 | }) 53 | }) 54 | 55 | context("when the error is exit.Fail", func() { 56 | it("exits with code 1", func() { 57 | handler.Error(internal.Fail) 58 | Expect(exitCode).To(Equal(100)) 59 | }) 60 | }) 61 | } 62 | -------------------------------------------------------------------------------- /internal/fail.go: -------------------------------------------------------------------------------- 1 | package internal 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | ) 7 | 8 | var Fail = failError{error: errors.New("failed")} 9 | 10 | type failError struct { 11 | error 12 | } 13 | 14 | func (f failError) WithMessage(format string, v ...interface{}) failError { 15 | return failError{error: fmt.Errorf(format, v...)} 16 | } 17 | -------------------------------------------------------------------------------- /internal/fail_test.go: -------------------------------------------------------------------------------- 1 | package internal_test 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/paketo-buildpacks/packit/v2/internal" 7 | "github.com/sclevine/spec" 8 | 9 | . "github.com/onsi/gomega" 10 | ) 11 | 12 | func testFail(t *testing.T, context spec.G, it spec.S) { 13 | var ( 14 | Expect = NewWithT(t).Expect 15 | ) 16 | 17 | it("acts as an error", func() { 18 | fail := internal.Fail 19 | Expect(fail).To(MatchError("failed")) 20 | }) 21 | 22 | context("when given a message", func() { 23 | it("acts as an error with that message", func() { 24 | fail := internal.Fail.WithMessage("this is a %s", "failure message") 25 | Expect(fail).To(MatchError("this is a failure message")) 26 | }) 27 | }) 28 | } 29 | -------------------------------------------------------------------------------- /internal/file_writer.go: -------------------------------------------------------------------------------- 1 | package internal 2 | 3 | import ( 4 | "io" 5 | "os" 6 | ) 7 | 8 | type FileWriter struct{} 9 | 10 | func NewFileWriter() FileWriter { 11 | return FileWriter{} 12 | } 13 | 14 | func (fw FileWriter) Write(path string, reader io.Reader) error { 15 | file, err := os.Create(path) 16 | if err != nil { 17 | return err 18 | } 19 | defer file.Close() 20 | 21 | _, err = io.Copy(file, reader) 22 | if err != nil { 23 | return err 24 | } 25 | 26 | return nil 27 | } 28 | -------------------------------------------------------------------------------- /internal/file_writer_test.go: -------------------------------------------------------------------------------- 1 | package internal_test 2 | 3 | import ( 4 | "errors" 5 | "os" 6 | "path/filepath" 7 | "strings" 8 | "testing" 9 | "testing/iotest" 10 | 11 | "github.com/paketo-buildpacks/packit/v2/internal" 12 | "github.com/sclevine/spec" 13 | 14 | . "github.com/onsi/gomega" 15 | ) 16 | 17 | func testFileWriter(t *testing.T, context spec.G, it spec.S) { 18 | var ( 19 | Expect = NewWithT(t).Expect 20 | 21 | tmpDir string 22 | path string 23 | fileWriter internal.FileWriter 24 | ) 25 | 26 | it.Before(func() { 27 | var err error 28 | tmpDir, err = os.MkdirTemp("", "file-writer") 29 | Expect(err).NotTo(HaveOccurred()) 30 | 31 | path = filepath.Join(tmpDir, "file.ext") 32 | }) 33 | 34 | it("writes the contents of a reader out to a file path", func() { 35 | err := fileWriter.Write(path, strings.NewReader("some-file-contents")) 36 | Expect(err).NotTo(HaveOccurred()) 37 | 38 | contents, err := os.ReadFile(path) 39 | Expect(err).NotTo(HaveOccurred()) 40 | Expect(string(contents)).To(Equal("some-file-contents")) 41 | }) 42 | 43 | context("failure cases", func() { 44 | context("when the file path cannot be created", func() { 45 | it.Before(func() { 46 | Expect(os.WriteFile(path, nil, 0000)).To(Succeed()) 47 | }) 48 | 49 | it("returns an error", func() { 50 | err := fileWriter.Write(path, strings.NewReader("some-file-contents")) 51 | Expect(err).To(MatchError(ContainSubstring("permission denied"))) 52 | }) 53 | }) 54 | 55 | context("when the reader throws an error", func() { 56 | it("returns an error", func() { 57 | err := fileWriter.Write(path, iotest.ErrReader(errors.New("failed to read"))) 58 | Expect(err).To(MatchError("failed to read")) 59 | }) 60 | }) 61 | }) 62 | } 63 | -------------------------------------------------------------------------------- /internal/init_test.go: -------------------------------------------------------------------------------- 1 | package internal_test 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/sclevine/spec" 7 | "github.com/sclevine/spec/report" 8 | ) 9 | 10 | func TestUnitInternal(t *testing.T) { 11 | suite := spec.New("packit/internal", spec.Report(report.Terminal{})) 12 | suite("EnvironmentWriter", testEnvironmentWriter) 13 | suite("ExitHandler", testExitHandler) 14 | suite("Fail", testFail) 15 | suite("FileWriter", testFileWriter) 16 | suite("TOMLWriter", testTOMLWriter) 17 | suite.Run(t) 18 | } 19 | -------------------------------------------------------------------------------- /internal/toml_writer.go: -------------------------------------------------------------------------------- 1 | package internal 2 | 3 | import ( 4 | "os" 5 | 6 | "github.com/pelletier/go-toml" 7 | ) 8 | 9 | type TOMLWriter struct{} 10 | 11 | func NewTOMLWriter() TOMLWriter { 12 | return TOMLWriter{} 13 | } 14 | 15 | func (tw TOMLWriter) Write(path string, value interface{}) error { 16 | file, err := os.OpenFile(path, os.O_CREATE|os.O_RDWR|os.O_TRUNC, 0644) 17 | if err != nil { 18 | return err 19 | } 20 | defer file.Close() 21 | 22 | return toml.NewEncoder(file).Encode(value) 23 | } 24 | -------------------------------------------------------------------------------- /internal/toml_writer_test.go: -------------------------------------------------------------------------------- 1 | package internal_test 2 | 3 | import ( 4 | "os" 5 | "path/filepath" 6 | "testing" 7 | 8 | "github.com/paketo-buildpacks/packit/v2/internal" 9 | "github.com/sclevine/spec" 10 | 11 | . "github.com/onsi/gomega" 12 | . "github.com/paketo-buildpacks/packit/v2/matchers" 13 | ) 14 | 15 | func testTOMLWriter(t *testing.T, context spec.G, it spec.S) { 16 | var ( 17 | Expect = NewWithT(t).Expect 18 | 19 | tmpDir string 20 | path string 21 | tomlWriter internal.TOMLWriter 22 | ) 23 | it.Before(func() { 24 | var err error 25 | tmpDir, err = os.MkdirTemp("", "tomlWriter") 26 | Expect(err).NotTo(HaveOccurred()) 27 | 28 | path = filepath.Join(tmpDir, "writer.toml") 29 | }) 30 | 31 | it("writes the contents of a given object out to a .toml file", func() { 32 | err := tomlWriter.Write(path, map[string]string{ 33 | "some-field": "some-value", 34 | "other-field": "other-value", 35 | }) 36 | Expect(err).NotTo(HaveOccurred()) 37 | 38 | tomlFileContents, err := os.ReadFile(path) 39 | Expect(err).NotTo(HaveOccurred()) 40 | Expect(string(tomlFileContents)).To(MatchTOML(` 41 | some-field = "some-value" 42 | other-field = "other-value"`)) 43 | }) 44 | 45 | context("failure cases", func() { 46 | context("the .toml file cannot be created", func() { 47 | it.Before(func() { 48 | Expect(os.RemoveAll(tmpDir)).To(Succeed()) 49 | }) 50 | 51 | it("returns an error", func() { 52 | err := tomlWriter.Write(path, map[string]string{ 53 | "some-field": "some-value", 54 | "other-field": "other-value", 55 | }) 56 | Expect(err).To(MatchError(ContainSubstring("no such file or directory"))) 57 | }) 58 | }) 59 | 60 | context("the TOML data is invalid", func() { 61 | 62 | it("returns an error", func() { 63 | err := tomlWriter.Write(path, 0) 64 | Expect(err).To(MatchError(ContainSubstring("Only a struct or map can be marshaled to TOML"))) 65 | }) 66 | }) 67 | }) 68 | } 69 | -------------------------------------------------------------------------------- /launch_metadata.go: -------------------------------------------------------------------------------- 1 | package packit 2 | 3 | // LaunchMetadata represents the launch metadata details persisted in the 4 | // launch.toml file according to the buildpack lifecycle specification: 5 | // https://github.com/buildpacks/spec/blob/main/buildpack.md#launchtoml-toml. 6 | type LaunchMetadata struct { 7 | // Processes is a list of processes that will be returned to the lifecycle to 8 | // be executed during the launch phase. 9 | Processes []Process 10 | 11 | // DirectProcesses is a list of processes that will be returned to the lifecycle to 12 | // be executed directly during the launch phase. 13 | DirectProcesses []DirectProcess 14 | 15 | // Slices is a list of slices that will be returned to the lifecycle to be 16 | // exported as separate layers during the export phase. 17 | Slices []Slice 18 | 19 | // Labels is a map of key-value pairs that will be returned to the lifecycle to be 20 | // added as config label on the image metadata. Keys must be unique. 21 | Labels map[string]string 22 | 23 | // BOM is the Bill-of-Material entries containing information about the 24 | // dependencies provided to the launch environment. 25 | BOM []BOMEntry 26 | 27 | // SBOM is a type that implements SBOMFormatter and declares the formats that 28 | // bill-of-materials should be output for the launch SBoM. 29 | SBOM SBOMFormatter 30 | } 31 | 32 | func (l LaunchMetadata) isEmpty() bool { 33 | var sbom []SBOMFormat 34 | if l.SBOM != nil { 35 | sbom = l.SBOM.Formats() 36 | } 37 | 38 | return len(sbom)+len(l.Processes)+len(l.DirectProcesses)+len(l.Slices)+len(l.Labels)+len(l.BOM) == 0 39 | } 40 | -------------------------------------------------------------------------------- /layers.go: -------------------------------------------------------------------------------- 1 | package packit 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "path/filepath" 7 | 8 | "github.com/BurntSushi/toml" 9 | ) 10 | 11 | // Layers represents the set of layers managed by a buildpack. 12 | type Layers struct { 13 | // Path is the absolute location of the set of layers managed by a buildpack 14 | // on disk. 15 | Path string 16 | } 17 | 18 | // Get will either create a new layer with the given name and layer types. If a 19 | // layer already exists on disk, then the layer metadata will be retrieved from 20 | // disk and returned instead. 21 | func (l Layers) Get(name string) (Layer, error) { 22 | layer := Layer{ 23 | Path: filepath.Join(l.Path, name), 24 | Name: name, 25 | SharedEnv: Environment{}, 26 | BuildEnv: Environment{}, 27 | LaunchEnv: Environment{}, 28 | ProcessLaunchEnv: make(map[string]Environment), 29 | } 30 | 31 | _, err := toml.DecodeFile(filepath.Join(l.Path, fmt.Sprintf("%s.toml", name)), &layer) 32 | if err != nil { 33 | if !os.IsNotExist(err) { 34 | return Layer{}, fmt.Errorf("failed to parse layer content metadata: %s", err) 35 | } 36 | } 37 | 38 | layer.SharedEnv, err = newEnvironmentFromPath(filepath.Join(l.Path, name, "env")) 39 | if err != nil { 40 | return Layer{}, err 41 | } 42 | 43 | layer.BuildEnv, err = newEnvironmentFromPath(filepath.Join(l.Path, name, "env.build")) 44 | if err != nil { 45 | return Layer{}, err 46 | } 47 | 48 | layer.LaunchEnv, err = newEnvironmentFromPath(filepath.Join(l.Path, name, "env.launch")) 49 | if err != nil { 50 | return Layer{}, err 51 | } 52 | 53 | if _, err := os.Stat(filepath.Join(l.Path, name, "env.launch")); !os.IsNotExist(err) { 54 | paths, err := os.ReadDir(filepath.Join(l.Path, name, "env.launch")) 55 | if err != nil { 56 | return Layer{}, err 57 | } 58 | 59 | for _, path := range paths { 60 | if path.IsDir() { 61 | layer.ProcessLaunchEnv[path.Name()], err = newEnvironmentFromPath(filepath.Join(l.Path, name, "env.launch", path.Name())) 62 | if err != nil { 63 | return Layer{}, err 64 | } 65 | } 66 | } 67 | } 68 | 69 | return layer, nil 70 | } 71 | -------------------------------------------------------------------------------- /matchers/contain_lines.go: -------------------------------------------------------------------------------- 1 | package matchers 2 | 3 | import ( 4 | "fmt" 5 | "reflect" 6 | "regexp" 7 | "strings" 8 | 9 | "github.com/onsi/gomega/format" 10 | "github.com/onsi/gomega/types" 11 | ) 12 | 13 | func ContainLines(expected ...interface{}) types.GomegaMatcher { 14 | return &containLinesMatcher{ 15 | expected: expected, 16 | } 17 | } 18 | 19 | type containLinesMatcher struct { 20 | expected []interface{} 21 | } 22 | 23 | func (matcher *containLinesMatcher) Match(actual interface{}) (success bool, err error) { 24 | _, ok := actual.(string) 25 | if !ok { 26 | _, ok := actual.(fmt.Stringer) 27 | if !ok { 28 | return false, fmt.Errorf("ContainLinesMatcher requires a string or fmt.Stringer. Got actual: %s", format.Object(actual, 1)) 29 | } 30 | } 31 | 32 | actualLines := matcher.lines(actual) 33 | 34 | for currentActualLineIndex := 0; currentActualLineIndex < len(actualLines); currentActualLineIndex++ { 35 | currentActualLine := actualLines[currentActualLineIndex] 36 | currentExpectedLine := matcher.expected[currentActualLineIndex] 37 | 38 | match, err := matcher.compare(currentActualLine, currentExpectedLine) 39 | if err != nil { 40 | return false, err 41 | } 42 | 43 | if match { 44 | if currentActualLineIndex+1 == len(matcher.expected) { 45 | return true, nil 46 | } 47 | } else { 48 | if len(actualLines) > 1 { 49 | actualLines = actualLines[1:] 50 | currentActualLineIndex = -1 51 | } 52 | } 53 | } 54 | 55 | return false, nil 56 | } 57 | 58 | func (matcher *containLinesMatcher) compare(actual string, expected interface{}) (bool, error) { 59 | if m, ok := expected.(types.GomegaMatcher); ok { 60 | match, err := m.Match(actual) 61 | if err != nil { 62 | return false, err 63 | } 64 | 65 | return match, nil 66 | } 67 | 68 | return reflect.DeepEqual(actual, expected), nil 69 | } 70 | 71 | func (matcher *containLinesMatcher) lines(actual interface{}) []string { 72 | raw, ok := actual.(string) 73 | if !ok { 74 | raw = actual.(fmt.Stringer).String() 75 | } 76 | 77 | re := regexp.MustCompile(`^\[[a-z]+\]\s`) 78 | 79 | var lines []string 80 | for _, line := range strings.Split(raw, "\n") { 81 | lines = append(lines, re.ReplaceAllString(line, "")) 82 | } 83 | 84 | return lines 85 | } 86 | 87 | func (matcher *containLinesMatcher) FailureMessage(actual interface{}) (message string) { 88 | actualLines := "\n" + strings.Join(matcher.lines(actual), "\n") 89 | missing := matcher.linesMatching(actual, false) 90 | if len(missing) > 0 { 91 | return fmt.Sprintf("Expected\n%s\nto contain lines\n%s\nbut missing\n%s", format.Object(actualLines, 1), format.Object(matcher.expected, 1), format.Object(missing, 1)) 92 | } 93 | 94 | return fmt.Sprintf("Expected\n%s\nto contain lines\n%s\nall lines appear, but may be misordered", format.Object(actualLines, 1), format.Object(matcher.expected, 1)) 95 | } 96 | 97 | func (matcher *containLinesMatcher) NegatedFailureMessage(actual interface{}) (message string) { 98 | actualLines := "\n" + strings.Join(matcher.lines(actual), "\n") 99 | missing := matcher.linesMatching(actual, true) 100 | 101 | return fmt.Sprintf("Expected\n%s\nnot to contain lines\n%s\nbut includes\n%s", format.Object(actualLines, 1), format.Object(matcher.expected, 1), format.Object(missing, 1)) 102 | } 103 | 104 | func (matcher *containLinesMatcher) linesMatching(actual interface{}, matching bool) []interface{} { 105 | var set []interface{} 106 | for _, expected := range matcher.expected { 107 | var match bool 108 | for _, line := range matcher.lines(actual) { 109 | if ok, _ := matcher.compare(line, expected); ok { 110 | match = true 111 | } 112 | } 113 | 114 | if match == matching { 115 | set = append(set, expected) 116 | } 117 | } 118 | 119 | return set 120 | } 121 | -------------------------------------------------------------------------------- /matchers/match_toml.go: -------------------------------------------------------------------------------- 1 | package matchers 2 | 3 | import ( 4 | "fmt" 5 | "reflect" 6 | 7 | "github.com/BurntSushi/toml" 8 | "github.com/onsi/gomega/types" 9 | ) 10 | 11 | func MatchTOML(expected interface{}) types.GomegaMatcher { 12 | return &matchTOML{ 13 | expected: expected, 14 | } 15 | } 16 | 17 | type matchTOML struct { 18 | expected interface{} 19 | } 20 | 21 | func (matcher *matchTOML) Match(actual interface{}) (success bool, err error) { 22 | var e, a string 23 | 24 | switch eType := matcher.expected.(type) { 25 | case string: 26 | e = eType 27 | case []byte: 28 | e = string(eType) 29 | default: 30 | return false, fmt.Errorf("expected value must be []byte or string, received %T", matcher.expected) 31 | } 32 | 33 | switch aType := actual.(type) { 34 | case string: 35 | a = aType 36 | case []byte: 37 | a = string(aType) 38 | default: 39 | return false, fmt.Errorf("actual value must be []byte or string, received %T", matcher.expected) 40 | } 41 | 42 | var eValue map[string]interface{} 43 | _, err = toml.Decode(e, &eValue) 44 | if err != nil { 45 | return false, err 46 | } 47 | 48 | var aValue map[string]interface{} 49 | _, err = toml.Decode(a, &aValue) 50 | if err != nil { 51 | return false, err 52 | } 53 | 54 | return reflect.DeepEqual(eValue, aValue), nil 55 | } 56 | 57 | func (matcher *matchTOML) FailureMessage(actual interface{}) (message string) { 58 | return fmt.Sprintf("Expected\n%s\nto match the TOML representation of\n%s", actual, matcher.expected) 59 | } 60 | 61 | func (matcher *matchTOML) NegatedFailureMessage(actual interface{}) (message string) { 62 | return fmt.Sprintf("Expected\n%s\nnot to match the TOML representation of\n%s", actual, matcher.expected) 63 | } 64 | -------------------------------------------------------------------------------- /option.go: -------------------------------------------------------------------------------- 1 | package packit 2 | 3 | import "io" 4 | 5 | // OptionConfig is the set of configurable options for the Build and Detect 6 | // functions. 7 | type OptionConfig struct { 8 | exitHandler ExitHandler 9 | args []string 10 | tomlWriter TOMLWriter 11 | envWriter EnvironmentWriter 12 | fileWriter FileWriter 13 | } 14 | 15 | // Option declares a function signature that can be used to define optional 16 | // modifications to the behavior of the Detect and Build functions. 17 | type Option func(config OptionConfig) OptionConfig 18 | 19 | //go:generate faux --interface ExitHandler --output fakes/exit_handler.go 20 | 21 | // ExitHandler serves as the interface for types that can handle an error 22 | // during the Detect or Build functions. ExitHandlers are responsible for 23 | // translating error values into exit codes according the specification: 24 | // https://github.com/buildpacks/spec/blob/main/buildpack.md#detection and 25 | // https://github.com/buildpacks/spec/blob/main/buildpack.md#build. 26 | type ExitHandler interface { 27 | Error(error) 28 | } 29 | 30 | // TOMLWriter serves as the interface for types that can handle the writing of 31 | // TOML files. TOMLWriters take a path to a file location on disk and a 32 | // datastructure to marshal. 33 | type TOMLWriter interface { 34 | Write(path string, value interface{}) error 35 | } 36 | 37 | // EnvironmentWriter serves as the interface for types that can write an 38 | // Environment to a directory on disk according to the specification: 39 | // https://github.com/buildpacks/spec/blob/main/buildpack.md#provided-by-the-buildpacks. 40 | type EnvironmentWriter interface { 41 | Write(dir string, env map[string]string) error 42 | } 43 | 44 | type FileWriter interface { 45 | Write(path string, reader io.Reader) error 46 | } 47 | 48 | // WithExitHandler is an Option that overrides the ExitHandler for a given 49 | // invocation of Build or Detect. 50 | func WithExitHandler(exitHandler ExitHandler) Option { 51 | return func(config OptionConfig) OptionConfig { 52 | config.exitHandler = exitHandler 53 | return config 54 | } 55 | } 56 | 57 | // WithArgs is an Option that overrides the value of os.Args for a given 58 | // invocation of Build or Detect. 59 | func WithArgs(args []string) Option { 60 | return func(config OptionConfig) OptionConfig { 61 | config.args = args 62 | return config 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /paketosbom/init_test.go: -------------------------------------------------------------------------------- 1 | package paketosbom_test 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/sclevine/spec" 7 | "github.com/sclevine/spec/report" 8 | ) 9 | 10 | func TestUnitPaketoSBOM(t *testing.T) { 11 | suite := spec.New("paketosbom", spec.Report(report.Terminal{})) 12 | suite("sbom", testPaketoSBOM) 13 | suite.Run(t) 14 | } 15 | -------------------------------------------------------------------------------- /paketosbom/sbom.go: -------------------------------------------------------------------------------- 1 | // Package paketosbom implements a standardized SBoM format that can be used in 2 | // Paketo Buildpacks. 3 | // 4 | // Deprecated: this package is frozen and will be removed in the next major 5 | // release of packit. 6 | package paketosbom 7 | 8 | import ( 9 | "fmt" 10 | "strings" 11 | "time" 12 | ) 13 | 14 | // BOMMetadata represents how the Paketo-specific implementation of 15 | // the Software Bill of Materials metadata components should be structured and named. 16 | type BOMMetadata struct { 17 | Architecture string `toml:"arch,omitempty"` 18 | CPE string `toml:"cpe,omitempty"` 19 | DeprecationDate time.Time `toml:"deprecation-date,omitempty"` 20 | Licenses []string `toml:"licenses,omitempty"` 21 | PURL string `toml:"purl,omitempty"` 22 | Checksum BOMChecksum `toml:"checksum,omitempty"` 23 | Summary string `toml:"summary,omitempty"` 24 | URI string `toml:"uri,omitempty"` 25 | Version string `toml:"version,omitempty"` 26 | Source BOMSource `toml:"source,omitempty"` 27 | } 28 | 29 | type BOMSource struct { 30 | Name string `toml:"name,omitempty"` 31 | Checksum BOMChecksum `toml:"checksum,omitempty"` 32 | UpstreamVersion string `toml:"upstream-version,omitempty"` 33 | URI string `toml:"uri,omitempty"` 34 | } 35 | 36 | type BOMChecksum struct { 37 | Algorithm ChecksumAlgorithm `toml:"algorithm,omitempty"` 38 | Hash string `toml:"hash,omitempty"` 39 | } 40 | 41 | type ChecksumAlgorithm interface { 42 | alg() algorithm 43 | } 44 | 45 | type algorithm string 46 | 47 | func (a algorithm) alg() algorithm { 48 | return a 49 | } 50 | 51 | // GetBOMChecksumAlgorithm takes in an algorithm string, and reasonably tries 52 | // to figure out the equivalent CycloneDX-supported algorithm field name. 53 | // It returns an error if no reasonable supported format is found. 54 | // Supported formats: 55 | // { 'MD5'| 'SHA-1'| 'SHA-256'| 'SHA-384'| 'SHA-512'| 'SHA3-256'| 'SHA3-384'| 'SHA3-512'| 'BLAKE2b-256'| 'BLAKE2b-384'| 'BLAKE2b-512'| 'BLAKE3'} 56 | func GetBOMChecksumAlgorithm(alg string) (algorithm, error) { 57 | for _, a := range []algorithm{SHA256, SHA1, SHA384, SHA512, SHA3256, SHA3384, SHA3512, BLAKE2B256, BLAKE2B384, BLAKE2B512, BLAKE3, MD5} { 58 | if strings.EqualFold(string(a), alg) || strings.EqualFold(strings.ReplaceAll(string(a), "-", ""), alg) { 59 | return a, nil 60 | } 61 | } 62 | 63 | return UNKNOWN, fmt.Errorf("failed to get supported BOM checksum algorithm: %s is not valid", alg) 64 | } 65 | 66 | const ( 67 | SHA256 algorithm = "SHA-256" 68 | SHA1 algorithm = "SHA-1" 69 | SHA384 algorithm = "SHA-384" 70 | SHA512 algorithm = "SHA-512" 71 | SHA3256 algorithm = "SHA3-256" 72 | SHA3384 algorithm = "SHA3-384" 73 | SHA3512 algorithm = "SHA3-512" 74 | BLAKE2B256 algorithm = "BLAKE2b-256" 75 | BLAKE2B384 algorithm = "BLAKE2b-384" 76 | BLAKE2B512 algorithm = "BLAKE2b-512" 77 | BLAKE3 algorithm = "BLAKE3" 78 | MD5 algorithm = "MD5" 79 | UNKNOWN algorithm = "UNKNOWN" 80 | ) 81 | -------------------------------------------------------------------------------- /paketosbom/sbom_test.go: -------------------------------------------------------------------------------- 1 | package paketosbom_test 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/sclevine/spec" 7 | 8 | . "github.com/onsi/gomega" 9 | 10 | //nolint Ignore SA1019, usage of deprecated package within a deprecated test case 11 | "github.com/paketo-buildpacks/packit/v2/paketosbom" 12 | ) 13 | 14 | func testPaketoSBOM(t *testing.T, context spec.G, it spec.S) { 15 | var ( 16 | Expect = NewWithT(t).Expect 17 | ) 18 | 19 | context("GetBOMChecksumAlgorithm", func() { 20 | context("given an algorithm string with the exact name of a CycloneDX algorithm", func() { 21 | it("returns the same algorithm name", func() { 22 | algorithm512, err := paketosbom.GetBOMChecksumAlgorithm("SHA-512") 23 | Expect(err).ToNot(HaveOccurred()) 24 | Expect(algorithm512).To(Equal(paketosbom.SHA512)) 25 | }) 26 | }) 27 | context("given an algorithm string with a lowercase version of a CycloneDX algorithm", func() { 28 | it("returns the Cyclonedx algorithm name", func() { 29 | algorithm512, err := paketosbom.GetBOMChecksumAlgorithm("sha-512") 30 | Expect(err).ToNot(HaveOccurred()) 31 | Expect(algorithm512).To(Equal(paketosbom.SHA512)) 32 | }) 33 | context("it also does not contain a dash", func() { 34 | it("returns the Cyclonedx algorithm name", func() { 35 | algorithm512, err := paketosbom.GetBOMChecksumAlgorithm("sha512") 36 | Expect(err).ToNot(HaveOccurred()) 37 | Expect(algorithm512).To(Equal(paketosbom.SHA512)) 38 | }) 39 | }) 40 | }) 41 | context("failure cases", func() { 42 | context("when the attempted BOM checksum algorithm is not supported", func() { 43 | it("persists a build.toml", func() { 44 | alg, err := paketosbom.GetBOMChecksumAlgorithm("RANDOM-ALG") 45 | Expect(alg).To(Equal(paketosbom.UNKNOWN)) 46 | Expect(err).To(MatchError("failed to get supported BOM checksum algorithm: RANDOM-ALG is not valid")) 47 | }) 48 | }) 49 | }) 50 | }) 51 | } 52 | -------------------------------------------------------------------------------- /pexec/doc.go: -------------------------------------------------------------------------------- 1 | // Package pexec provides a mechanism for invoking a program executable with a 2 | // varying set of arguments. 3 | // 4 | // Below is an example showing how you might invoke the `echo` executable with arguments; 5 | // 6 | // package main 7 | // 8 | // import ( 9 | // "os" 10 | // 11 | // "github.com/paketo-buildpacks/packit/v2/pexec" 12 | // ) 13 | // 14 | // func main() { 15 | // echo := pexec.NewExecutable("echo") 16 | // 17 | // err := echo.Execute(pexec.Execution{ 18 | // Args: []string{"hello from pexec"}, 19 | // Stdout: os.Stdout, 20 | // }) 21 | // if err != nil { 22 | // panic(err) 23 | // } 24 | // 25 | // // Output: hello from pexec 26 | // } 27 | // 28 | package pexec 29 | -------------------------------------------------------------------------------- /pexec/executable.go: -------------------------------------------------------------------------------- 1 | package pexec 2 | 3 | import ( 4 | "io" 5 | "os" 6 | "os/exec" 7 | "strings" 8 | ) 9 | 10 | // Executable represents an executable on the $PATH. 11 | type Executable struct { 12 | name string 13 | } 14 | 15 | // NewExecutable returns an instance of an Executable given the name of that 16 | // executable. When given simply a name, the execuable will be looked up on the 17 | // $PATH before execution. Alternatively, when given a path, the executable 18 | // will use that path to invoke the executable file directly. 19 | func NewExecutable(name string) Executable { 20 | return Executable{ 21 | name: name, 22 | } 23 | } 24 | 25 | // Execute invokes the executable with a set of Execution arguments. 26 | func (e Executable) Execute(execution Execution) error { 27 | envPath := os.Getenv("PATH") 28 | 29 | if execution.Env != nil { 30 | var path string 31 | for _, variable := range execution.Env { 32 | if strings.HasPrefix(variable, "PATH=") { 33 | path = strings.TrimPrefix(variable, "PATH=") 34 | } 35 | } 36 | if path != "" { 37 | os.Setenv("PATH", path) 38 | } 39 | } 40 | 41 | executable, err := exec.LookPath(e.name) 42 | if err != nil { 43 | return err 44 | } 45 | 46 | os.Setenv("PATH", envPath) 47 | 48 | cmd := exec.Command(executable, execution.Args...) 49 | 50 | if execution.Dir != "" { 51 | cmd.Dir = execution.Dir 52 | } 53 | 54 | if len(execution.Env) > 0 { 55 | cmd.Env = execution.Env 56 | } 57 | 58 | cmd.Stdout = execution.Stdout 59 | cmd.Stderr = execution.Stderr 60 | cmd.Stdin = execution.Stdin 61 | 62 | return cmd.Run() 63 | } 64 | 65 | // Execution is the set of configurable options for a given execution of the 66 | // executable. 67 | type Execution struct { 68 | // Args is a list of the arguments to be passed to the executable. 69 | Args []string 70 | 71 | // Dir is the path to a directory from with the executable should be invoked. 72 | // If Dir is not set, the current working directory will be used. 73 | Dir string 74 | 75 | // Env is the set of environment variables that make up the environment for 76 | // the execution. If Env is not set, the existing os.Environ value will be 77 | // used. 78 | Env []string 79 | 80 | // Stdout is where the output of stdout will be written during the execution. 81 | Stdout io.Writer 82 | 83 | // Stderr is where the output of stderr will be written during the execution. 84 | Stderr io.Writer 85 | 86 | // Stdin is where the input of stdin will be read during the execution. 87 | Stdin io.Reader 88 | } 89 | -------------------------------------------------------------------------------- /pexec/init_test.go: -------------------------------------------------------------------------------- 1 | package pexec_test 2 | 3 | import ( 4 | "os" 5 | "path/filepath" 6 | "testing" 7 | 8 | "github.com/onsi/gomega/gexec" 9 | "github.com/sclevine/spec" 10 | "github.com/sclevine/spec/report" 11 | 12 | . "github.com/onsi/gomega" 13 | ) 14 | 15 | var ( 16 | existingPath string 17 | fakeCLI string 18 | ) 19 | 20 | func TestUnitPexec(t *testing.T) { 21 | var Expect = NewWithT(t).Expect 22 | 23 | suite := spec.New("packit/pexec", spec.Report(report.Terminal{})) 24 | suite("pexec", testPexec) 25 | 26 | var err error 27 | fakeCLI, err = gexec.Build("github.com/paketo-buildpacks/packit/v2/fakes/some-executable") 28 | Expect(err).NotTo(HaveOccurred()) 29 | 30 | existingPath = os.Getenv("PATH") 31 | os.Setenv("PATH", filepath.Dir(fakeCLI)) 32 | 33 | t.Cleanup(func() { 34 | os.Setenv("PATH", existingPath) 35 | gexec.CleanupBuildArtifacts() 36 | }) 37 | 38 | suite.Run(t) 39 | } 40 | -------------------------------------------------------------------------------- /platform.go: -------------------------------------------------------------------------------- 1 | package packit 2 | 3 | // Platform contains the context of the buildpack platform including its 4 | // location on the filesystem. 5 | type Platform struct { 6 | // Path provides the location of the platform directory on the filesystem. 7 | // This location can be used to find platform extensions like service 8 | // bindings. 9 | Path string 10 | } 11 | -------------------------------------------------------------------------------- /postal/buildpack.go: -------------------------------------------------------------------------------- 1 | package postal 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "time" 7 | 8 | "github.com/BurntSushi/toml" 9 | "github.com/paketo-buildpacks/packit/v2/cargo" 10 | ) 11 | 12 | type Checksum = cargo.Checksum 13 | 14 | // Dependency is a representation of a buildpack dependency. 15 | type Dependency struct { 16 | // CPE is the Common Platform Enumerator for the dependency. Used in legacy 17 | // image label SBOM (GenerateBillOfMaterials). 18 | // 19 | // Deprecated: use CPEs instead. 20 | CPE string `toml:"cpe"` 21 | 22 | // CPEs are the Common Platform Enumerators for the dependency. Used in Syft 23 | // and SPDX JSON SBOMs. If unset, falls back to CPE. 24 | CPEs []string `toml:"cpes"` 25 | 26 | // DeprecationDate is the data upon which this dependency is considered deprecated. 27 | DeprecationDate time.Time `toml:"deprecation_date"` 28 | 29 | // Checksum is a string that includes an algorithm and the hex-encoded hash 30 | // of the built dependency separated by a colon. Example 31 | // sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855. 32 | Checksum string `toml:"checksum"` 33 | 34 | // ID is the identifier used to specify the dependency. 35 | ID string `toml:"id"` 36 | 37 | // Licenses is a list of SPDX license identifiers of licenses in the dependency. 38 | Licenses []string `toml:"licenses"` 39 | 40 | // Name is the human-readable name of the dependency. 41 | Name string `toml:"name"` 42 | 43 | // PURL is the package URL for the dependency. 44 | PURL string `toml:"purl"` 45 | 46 | // SHA256 is the hex-encoded SHA256 checksum of the built dependency. 47 | // 48 | // Deprecated: use Checksum instead. 49 | SHA256 string `toml:"sha256"` 50 | 51 | // Source is the uri location of the source-code representation of the dependency. 52 | Source string `toml:"source"` 53 | 54 | // SourceChecksum is a string that includes an algorithm and the hex-encoded 55 | // hash of the source representation of the dependency separated by a colon. 56 | // Example sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855. 57 | SourceChecksum string `toml:"source-checksum"` 58 | 59 | // SourceSHA256 is the hex-encoded SHA256 checksum of the source-code 60 | // representation of the dependency. 61 | // 62 | // Deprecated: use SourceChecksum instead. 63 | SourceSHA256 string `toml:"source_sha256"` 64 | 65 | // Stacks is a list of stacks for which the dependency is built. 66 | Stacks []string `toml:"stacks"` 67 | 68 | // URI is the uri location of the built dependency. 69 | URI string `toml:"uri"` 70 | 71 | // Version is the specific version of the dependency. 72 | Version string `toml:"version"` 73 | 74 | // StripComponents behaves like the --strip-components flag on tar command 75 | // removing the first n levels from the final decompression destination. 76 | StripComponents int `toml:"strip-components"` 77 | 78 | // ARCH is the architecture of this dependency 79 | Arch string `toml:"arch"` 80 | } 81 | 82 | func parseBuildpack(path, name string) ([]Dependency, string, error) { 83 | file, err := os.Open(path) 84 | if err != nil { 85 | return nil, "", fmt.Errorf("failed to parse buildpack.toml: %w", err) 86 | } 87 | 88 | var buildpack struct { 89 | Metadata struct { 90 | DefaultVersions map[string]string `toml:"default-versions"` 91 | Dependencies []Dependency `toml:"dependencies"` 92 | } `toml:"metadata"` 93 | } 94 | _, err = toml.NewDecoder(file).Decode(&buildpack) 95 | if err != nil { 96 | return nil, "", fmt.Errorf("failed to parse buildpack.toml: %w", err) 97 | } 98 | 99 | return buildpack.Metadata.Dependencies, buildpack.Metadata.DefaultVersions[name], nil 100 | } 101 | 102 | func stacksInclude(stacks []string, stack string) bool { 103 | for _, s := range stacks { 104 | if s == stack || s == "*" { 105 | return true 106 | } 107 | } 108 | return false 109 | } 110 | -------------------------------------------------------------------------------- /postal/doc.go: -------------------------------------------------------------------------------- 1 | // Package postal provides a service for resolving and installing dependencies 2 | // for a buildpack. 3 | // 4 | // Below is an example that show the resolution and installation of a "node" dependency: 5 | // 6 | // package main 7 | // 8 | // import ( 9 | // "log" 10 | // 11 | // "github.com/paketo-buildpacks/packit/v2/cargo" 12 | // "github.com/paketo-buildpacks/packit/v2/postal" 13 | // ) 14 | // 15 | // func main() { 16 | // // Here we construct a transport and service so that we can download or fetch 17 | // // dependencies from a cache and install them into a layer. 18 | // transport := cargo.NewTransport() 19 | // service := postal.NewService(transport) 20 | // 21 | // // The Resolve method can be used to pick a dependency that best matches a 22 | // // set of criteria including id, version constraint, and stack. 23 | // dependency, err := service.Resolve("/cnbs/com.example.nodejs-cnb/buildpack.toml", "node", "10.*", "com.example.stacks.bionic") 24 | // if err != nil { 25 | // log.Fatal(err) 26 | // } 27 | // 28 | // // The Install method will download or fetch the given dependency and ensure 29 | // // it is expanded into the given layer path as well as validated against its 30 | // // SHA256 checksum. 31 | // err = service.Install(dependency, "/cnbs/com.example.nodejs-cnb", "/layers/com.example.nodejs-cnb/node") 32 | // if err != nil { 33 | // log.Fatal(err) 34 | // } 35 | // } 36 | // 37 | package postal 38 | -------------------------------------------------------------------------------- /postal/fakes/mapping_resolver.go: -------------------------------------------------------------------------------- 1 | package fakes 2 | 3 | import "sync" 4 | 5 | type MappingResolver struct { 6 | FindDependencyMappingCall struct { 7 | mutex sync.Mutex 8 | CallCount int 9 | Receives struct { 10 | Checksum string 11 | PlatformDir string 12 | } 13 | Returns struct { 14 | String string 15 | Error error 16 | } 17 | Stub func(string, string) (string, error) 18 | } 19 | } 20 | 21 | func (f *MappingResolver) FindDependencyMapping(param1 string, param2 string) (string, error) { 22 | f.FindDependencyMappingCall.mutex.Lock() 23 | defer f.FindDependencyMappingCall.mutex.Unlock() 24 | f.FindDependencyMappingCall.CallCount++ 25 | f.FindDependencyMappingCall.Receives.Checksum = param1 26 | f.FindDependencyMappingCall.Receives.PlatformDir = param2 27 | if f.FindDependencyMappingCall.Stub != nil { 28 | return f.FindDependencyMappingCall.Stub(param1, param2) 29 | } 30 | return f.FindDependencyMappingCall.Returns.String, f.FindDependencyMappingCall.Returns.Error 31 | } 32 | -------------------------------------------------------------------------------- /postal/fakes/mirror_resolver.go: -------------------------------------------------------------------------------- 1 | package fakes 2 | 3 | import "sync" 4 | 5 | type MirrorResolver struct { 6 | FindDependencyMirrorCall struct { 7 | mutex sync.Mutex 8 | CallCount int 9 | Receives struct { 10 | Uri string 11 | PlatformDir string 12 | } 13 | Returns struct { 14 | String string 15 | Error error 16 | } 17 | Stub func(string, string) (string, error) 18 | } 19 | } 20 | 21 | func (f *MirrorResolver) FindDependencyMirror(param1 string, param2 string) (string, error) { 22 | f.FindDependencyMirrorCall.mutex.Lock() 23 | defer f.FindDependencyMirrorCall.mutex.Unlock() 24 | f.FindDependencyMirrorCall.CallCount++ 25 | f.FindDependencyMirrorCall.Receives.Uri = param1 26 | f.FindDependencyMirrorCall.Receives.PlatformDir = param2 27 | if f.FindDependencyMirrorCall.Stub != nil { 28 | return f.FindDependencyMirrorCall.Stub(param1, param2) 29 | } 30 | return f.FindDependencyMirrorCall.Returns.String, f.FindDependencyMirrorCall.Returns.Error 31 | } 32 | -------------------------------------------------------------------------------- /postal/fakes/transport.go: -------------------------------------------------------------------------------- 1 | package fakes 2 | 3 | import ( 4 | "io" 5 | "sync" 6 | ) 7 | 8 | type Transport struct { 9 | DropCall struct { 10 | mutex sync.Mutex 11 | CallCount int 12 | Receives struct { 13 | Root string 14 | Uri string 15 | } 16 | Returns struct { 17 | ReadCloser io.ReadCloser 18 | Error error 19 | } 20 | Stub func(string, string) (io.ReadCloser, error) 21 | } 22 | } 23 | 24 | func (f *Transport) Drop(param1 string, param2 string) (io.ReadCloser, error) { 25 | f.DropCall.mutex.Lock() 26 | defer f.DropCall.mutex.Unlock() 27 | f.DropCall.CallCount++ 28 | f.DropCall.Receives.Root = param1 29 | f.DropCall.Receives.Uri = param2 30 | if f.DropCall.Stub != nil { 31 | return f.DropCall.Stub(param1, param2) 32 | } 33 | return f.DropCall.Returns.ReadCloser, f.DropCall.Returns.Error 34 | } 35 | -------------------------------------------------------------------------------- /postal/init_test.go: -------------------------------------------------------------------------------- 1 | package postal_test 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/sclevine/spec" 7 | "github.com/sclevine/spec/report" 8 | ) 9 | 10 | func TestUnitPostal(t *testing.T) { 11 | suite := spec.New("packit/postal", spec.Report(report.Terminal{})) 12 | suite("Service", testService) 13 | 14 | suite.Run(t) 15 | } 16 | -------------------------------------------------------------------------------- /postal/internal/dependency_mappings.go: -------------------------------------------------------------------------------- 1 | package internal 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | 7 | "github.com/paketo-buildpacks/packit/v2/cargo" 8 | "github.com/paketo-buildpacks/packit/v2/servicebindings" 9 | ) 10 | 11 | //go:generate faux --interface BindingResolver --output fakes/binding_resolver.go 12 | type BindingResolver interface { 13 | Resolve(typ, provider, platformDir string) ([]servicebindings.Binding, error) 14 | } 15 | 16 | type DependencyMappingResolver struct { 17 | bindingResolver BindingResolver 18 | } 19 | 20 | func NewDependencyMappingResolver(bindingResolver BindingResolver) DependencyMappingResolver { 21 | return DependencyMappingResolver{ 22 | bindingResolver: bindingResolver, 23 | } 24 | } 25 | 26 | // FindDependencyMapping looks up if there is a matching dependency mapping 27 | // If the binding is given in the form of `hash`, assume it is of algorithm `sha256` 28 | // If the binding is given in the form of `algorithm:hash`, compare it to the full `checksum` input 29 | func (d DependencyMappingResolver) FindDependencyMapping(checksum, platformDir string) (string, error) { 30 | bindings, err := d.bindingResolver.Resolve("dependency-mapping", "", platformDir) 31 | if err != nil { 32 | return "", fmt.Errorf("failed to resolve 'dependency-mapping' binding: %w", err) 33 | } 34 | 35 | hash := cargo.Checksum(checksum).Hash() 36 | 37 | for _, binding := range bindings { 38 | // binding provided in the form `hash` (no algorithm provided) 39 | // assumed to be of `sha256` algorithm 40 | if uri, ok := binding.Entries[hash]; ok && cargo.Checksum(checksum).Algorithm() == "sha256" { 41 | content, err := uri.ReadString() 42 | if err != nil { 43 | return "", err 44 | } 45 | return strings.TrimSpace(content), nil 46 | // binding provided in the form `algorithm:hash` 47 | } else if uri, ok := binding.Entries[checksum]; ok { 48 | content, err := uri.ReadString() 49 | if err != nil { 50 | return "", err 51 | } 52 | return strings.TrimSpace(content), nil 53 | // binding provided in the form `algorithm_hash` 54 | } else if uri, ok := binding.Entries[strings.Replace(checksum, ":", "_", 1)]; ok { 55 | content, err := uri.ReadString() 56 | if err != nil { 57 | return "", err 58 | } 59 | return strings.TrimSpace(content), nil 60 | } 61 | } 62 | 63 | return "", nil 64 | } 65 | -------------------------------------------------------------------------------- /postal/internal/dependency_mirror.go: -------------------------------------------------------------------------------- 1 | package internal 2 | 3 | import ( 4 | "fmt" 5 | "net/url" 6 | "os" 7 | "strings" 8 | ) 9 | 10 | type DependencyMirrorResolver struct { 11 | bindingResolver BindingResolver 12 | } 13 | 14 | func NewDependencyMirrorResolver(bindingResolver BindingResolver) DependencyMirrorResolver { 15 | return DependencyMirrorResolver{ 16 | bindingResolver: bindingResolver, 17 | } 18 | } 19 | 20 | func formatAndVerifyMirror(mirror, uri string) (string, error) { 21 | mirrorURL, err := url.Parse(mirror) 22 | if err != nil { 23 | return "", err 24 | } 25 | 26 | uriURL, err := url.Parse(uri) 27 | if err != nil { 28 | return "", err 29 | } 30 | 31 | if strings.ToLower(mirrorURL.Scheme) != "https" && strings.ToLower(mirrorURL.Scheme) != "file" { 32 | return "", fmt.Errorf("invalid mirror scheme") 33 | } 34 | 35 | mirrorURL.Path = strings.Replace(mirrorURL.Path, "{originalHost}", uriURL.Hostname(), 1) + uriURL.Path 36 | return mirrorURL.String(), nil 37 | } 38 | 39 | func (d DependencyMirrorResolver) FindDependencyMirror(uri, platformDir string) (string, error) { 40 | mirror, err := d.findMirrorFromEnv(uri) 41 | if err != nil { 42 | return "", err 43 | } 44 | 45 | if mirror != "" { 46 | return formatAndVerifyMirror(mirror, uri) 47 | } 48 | 49 | mirror, err = d.findMirrorFromBinding(uri, platformDir) 50 | if err != nil { 51 | return "", err 52 | } 53 | 54 | if mirror != "" { 55 | return formatAndVerifyMirror(mirror, uri) 56 | } 57 | 58 | return "", nil 59 | } 60 | 61 | func (d DependencyMirrorResolver) findMirrorFromEnv(uri string) (string, error) { 62 | const DefaultMirror = "BP_DEPENDENCY_MIRROR" 63 | const NonDefaultMirrorPrefix = "BP_DEPENDENCY_MIRROR_" 64 | mirrors := make(map[string]string) 65 | environmentVariables := os.Environ() 66 | for _, ev := range environmentVariables { 67 | pair := strings.SplitN(ev, "=", 2) 68 | key := pair[0] 69 | value := pair[1] 70 | 71 | if !strings.Contains(key, DefaultMirror) { 72 | continue 73 | } 74 | 75 | if key == DefaultMirror { 76 | mirrors["default"] = value 77 | continue 78 | } 79 | 80 | // convert key 81 | hostname := strings.SplitN(key, NonDefaultMirrorPrefix, 2)[1] 82 | hostname = strings.ReplaceAll(strings.ReplaceAll(hostname, "__", "-"), "_", ".") 83 | hostname = strings.ToLower(hostname) 84 | mirrors[hostname] = value 85 | 86 | if !strings.Contains(uri, hostname) { 87 | continue 88 | } 89 | 90 | return value, nil 91 | } 92 | 93 | if mirrorUri, ok := mirrors["default"]; ok { 94 | return mirrorUri, nil 95 | } 96 | 97 | return "", nil 98 | } 99 | 100 | func (d DependencyMirrorResolver) findMirrorFromBinding(uri, platformDir string) (string, error) { 101 | bindings, err := d.bindingResolver.Resolve("dependency-mirror", "", platformDir) 102 | if err != nil { 103 | return "", fmt.Errorf("failed to resolve 'dependency-mirror' binding: %w", err) 104 | } 105 | 106 | if len(bindings) > 1 { 107 | return "", fmt.Errorf("cannot have multiple bindings of type 'dependency-mirror'") 108 | } 109 | 110 | if len(bindings) == 0 { 111 | return "", nil 112 | } 113 | 114 | mirror := "" 115 | entries := bindings[0].Entries 116 | for hostname, entry := range entries { 117 | if hostname == "default" { 118 | mirror, err = entry.ReadString() 119 | if err != nil { 120 | return "", err 121 | } 122 | continue 123 | } 124 | 125 | if !strings.Contains(uri, hostname) { 126 | continue 127 | } 128 | 129 | mirror, err = entry.ReadString() 130 | if err != nil { 131 | return "", err 132 | } 133 | 134 | return mirror, nil 135 | } 136 | 137 | return mirror, nil 138 | } 139 | -------------------------------------------------------------------------------- /postal/internal/fakes/binding_resolver.go: -------------------------------------------------------------------------------- 1 | package fakes 2 | 3 | import ( 4 | "sync" 5 | 6 | "github.com/paketo-buildpacks/packit/v2/servicebindings" 7 | ) 8 | 9 | type BindingResolver struct { 10 | ResolveCall struct { 11 | mutex sync.Mutex 12 | CallCount int 13 | Receives struct { 14 | Typ string 15 | Provider string 16 | PlatformDir string 17 | } 18 | Returns struct { 19 | BindingSlice []servicebindings.Binding 20 | Error error 21 | } 22 | Stub func(string, string, string) ([]servicebindings.Binding, error) 23 | } 24 | } 25 | 26 | func (f *BindingResolver) Resolve(param1 string, param2 string, param3 string) ([]servicebindings.Binding, error) { 27 | f.ResolveCall.mutex.Lock() 28 | defer f.ResolveCall.mutex.Unlock() 29 | f.ResolveCall.CallCount++ 30 | f.ResolveCall.Receives.Typ = param1 31 | f.ResolveCall.Receives.Provider = param2 32 | f.ResolveCall.Receives.PlatformDir = param3 33 | if f.ResolveCall.Stub != nil { 34 | return f.ResolveCall.Stub(param1, param2, param3) 35 | } 36 | return f.ResolveCall.Returns.BindingSlice, f.ResolveCall.Returns.Error 37 | } 38 | -------------------------------------------------------------------------------- /postal/internal/init_test.go: -------------------------------------------------------------------------------- 1 | package internal_test 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/sclevine/spec" 7 | "github.com/sclevine/spec/report" 8 | ) 9 | 10 | func TestUnitPostalInternal(t *testing.T) { 11 | suite := spec.New("packit/postal/internal", spec.Report(report.Terminal{})) 12 | suite("DependencyMappings", testDependencyMappings) 13 | suite("DependencyMirror", testDependencyMirror) 14 | 15 | suite.Run(t) 16 | } 17 | -------------------------------------------------------------------------------- /process.go: -------------------------------------------------------------------------------- 1 | package packit 2 | 3 | // Process represents a process to be run during the launch phase as described 4 | // in the specification lower than v0.9: 5 | // https://github.com/buildpacks/spec/blob/main/buildpack.md#launch. The 6 | // fields of the process are describe in the specification of the launch.toml 7 | // file: 8 | // https://github.com/buildpacks/spec/blob/main/buildpack.md#launchtoml-toml. 9 | type Process struct { 10 | // Type is an identifier to describe the type of process to be executed, eg. 11 | // "web". 12 | Type string `toml:"type"` 13 | 14 | // Command is the start command to be executed at launch. 15 | Command string `toml:"command"` 16 | 17 | // Args is a list of arguments to be passed to the command at launch. 18 | Args []string `toml:"args"` 19 | 20 | // Direct indicates whether the process should bypass the shell when invoked. 21 | Direct bool `toml:"direct"` 22 | 23 | // Default indicates if this process should be the default when launched. 24 | Default bool `toml:"default,omitempty"` 25 | 26 | // WorkingDirectory indicates if this process should be run in a working 27 | // directory other than the application directory. This can either be an 28 | // absolute path or one relative to the default application directory. 29 | WorkingDirectory string `toml:"working-directory,omitempty"` 30 | } 31 | 32 | // DirectProcess represents a process to be run during the launch phase as described 33 | // in the specification higher or equal than v0.9: 34 | // https://github.com/buildpacks/spec/blob/main/buildpack.md#launch. The 35 | // fields of the process are describe in the specification of the launch.toml 36 | // file: 37 | // https://github.com/buildpacks/spec/blob/main/buildpack.md#launchtoml-toml. 38 | type DirectProcess struct { 39 | // Type is an identifier to describe the type of process to be executed, eg. 40 | // "web". 41 | Type string `toml:"type"` 42 | 43 | // Command is the start command to be executed at launch. 44 | Command []string `toml:"command"` 45 | 46 | // Args is a list of arguments to be passed to the command at launch. 47 | Args []string `toml:"args"` 48 | 49 | // Default indicates if this process should be the default when launched. 50 | Default bool `toml:"default,omitempty"` 51 | 52 | // WorkingDirectory indicates if this process should be run in a working 53 | // directory other than the application directory. This can either be an 54 | // absolute path or one relative to the default application directory. 55 | WorkingDirectory string `toml:"working-directory,omitempty"` 56 | } 57 | -------------------------------------------------------------------------------- /run.go: -------------------------------------------------------------------------------- 1 | package packit 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "path/filepath" 7 | 8 | "github.com/paketo-buildpacks/packit/v2/internal" 9 | ) 10 | 11 | // Run combines the invocation of both build and detect into a single entry 12 | // point. Calling Run from an executable with a name matching "build" or 13 | // "detect" will result in the matching DetectFunc or BuildFunc being called. 14 | func Run(detect DetectFunc, build BuildFunc, options ...Option) { 15 | config := OptionConfig{ 16 | exitHandler: internal.NewExitHandler(), 17 | args: os.Args, 18 | } 19 | 20 | for _, option := range options { 21 | config = option(config) 22 | } 23 | 24 | phase := filepath.Base(config.args[0]) 25 | 26 | switch phase { 27 | case "detect": 28 | Detect(detect, options...) 29 | case "build": 30 | Build(build, options...) 31 | default: 32 | config.exitHandler.Error(fmt.Errorf("failed to run buildpack: unknown lifecycle phase %q", phase)) 33 | } 34 | 35 | } 36 | 37 | // RunExtension combines the invocation of both generate and detect into a single entry 38 | // point. Calling Run from an executable with a name matching "generate" or 39 | // "detect" will result in the matching DetectFunc or GenerateFunc being called. 40 | func RunExtension(detect DetectFunc, generate GenerateFunc, options ...Option) { 41 | config := OptionConfig{ 42 | exitHandler: internal.NewExitHandler(), 43 | args: os.Args, 44 | } 45 | 46 | for _, option := range options { 47 | config = option(config) 48 | } 49 | 50 | phase := filepath.Base(config.args[0]) 51 | 52 | switch phase { 53 | case "detect": 54 | Detect(detect, options...) 55 | case "generate": 56 | Generate(generate, options...) 57 | default: 58 | config.exitHandler.Error(fmt.Errorf("failed to run buildpack: unknown lifecycle phase %q", phase)) 59 | } 60 | 61 | } 62 | -------------------------------------------------------------------------------- /sbom/formats.go: -------------------------------------------------------------------------------- 1 | package sbom 2 | 3 | import ( 4 | syftsbom "github.com/anchore/syft/syft/sbom" 5 | ) 6 | 7 | const ( 8 | SyftFormat = "application/vnd.syft+json" 9 | CycloneDXFormat = "application/vnd.cyclonedx+json" 10 | SPDXFormat = "application/spdx+json" 11 | 12 | SyftLatest = syftsbom.FormatID("syft-json") 13 | 14 | CycloneLatest = syftsbom.FormatID("cyclonedx-json") 15 | CycloneDX13 = syftsbom.FormatID("cyclonedx-json@1.3") 16 | CycloneDX14 = syftsbom.FormatID("cyclonedx-json@1.4") 17 | 18 | SPDXLatest = syftsbom.FormatID("spdx-json") 19 | SPDX22 = syftsbom.FormatID("spdx-json@2.2") 20 | ) 21 | 22 | // Format is the type declaration for the supported SBoM output formats. 23 | type Format string 24 | 25 | // Extension outputs the expected file extension for a given Format. 26 | func (f Format) Extension() string { 27 | switch f { 28 | case CycloneDXFormat: 29 | return "cdx.json" 30 | case SPDXFormat: 31 | return "spdx.json" 32 | case SyftFormat: 33 | return "syft.json" 34 | default: 35 | return "" 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /sbom/formatter.go: -------------------------------------------------------------------------------- 1 | package sbom 2 | 3 | import ( 4 | "github.com/anchore/syft/syft/sbom" 5 | "github.com/paketo-buildpacks/packit/v2" 6 | ) 7 | 8 | // Formatter implements the packit.SBOMFormatter interface. 9 | type Formatter struct { 10 | sbom SBOM 11 | formatIDs []sbom.FormatID 12 | } 13 | 14 | // Formats returns a list of packit.SBOMFormat instances. 15 | func (f Formatter) Formats() []packit.SBOMFormat { 16 | var formats []packit.SBOMFormat 17 | for _, id := range f.formatIDs { 18 | // ignore error here; FormattedReader validates SBOM format before Read() 19 | format, _ := sbomFormatByID(id) 20 | formats = append(formats, packit.SBOMFormat{ 21 | Extension: format.Extension(), 22 | // type conversion here to maintain backward compatibility of NewFormattedReader 23 | Content: NewFormattedReader(f.sbom, Format(id)), 24 | }) 25 | } 26 | 27 | return formats 28 | } 29 | -------------------------------------------------------------------------------- /sbom/formatter_test.go: -------------------------------------------------------------------------------- 1 | package sbom_test 2 | 3 | import ( 4 | "io" 5 | "testing" 6 | 7 | "github.com/paketo-buildpacks/packit/v2/sbom" 8 | "github.com/sclevine/spec" 9 | 10 | . "github.com/onsi/gomega" 11 | ) 12 | 13 | func testFormatter(t *testing.T, context spec.G, it spec.S) { 14 | var ( 15 | Expect = NewWithT(t).Expect 16 | 17 | formatter sbom.Formatter 18 | ) 19 | 20 | it.Before(func() { 21 | bom, err := sbom.Generate("testdata/") 22 | Expect(err).NotTo(HaveOccurred()) 23 | 24 | formatter, err = bom.InFormats(sbom.CycloneDXFormat, sbom.SPDXFormat, sbom.SyftFormat) 25 | Expect(err).NotTo(HaveOccurred()) 26 | }) 27 | 28 | context("Format", func() { 29 | it("returns a copy of the original map", func() { 30 | // Assert that the first copy contains all of the right formats 31 | formats := formatter.Formats() 32 | Expect(formats).To(HaveLen(3)) 33 | 34 | var extensions []string 35 | for _, format := range formats { 36 | extensions = append(extensions, format.Extension) 37 | } 38 | 39 | Expect(extensions).To(ConsistOf("cdx.json", "spdx.json", "syft.json")) 40 | 41 | for _, format := range formats { 42 | content, err := io.ReadAll(format.Content) 43 | Expect(err).NotTo(HaveOccurred()) 44 | Expect(content).NotTo(BeEmpty()) 45 | } 46 | 47 | // Assert that the second copy contains all of the right formats and that 48 | // the readers are repopulated 49 | formats = formatter.Formats() 50 | Expect(formats).To(HaveLen(3)) 51 | 52 | extensions = []string{} 53 | for _, format := range formats { 54 | extensions = append(extensions, format.Extension) 55 | } 56 | 57 | Expect(extensions).To(ConsistOf("cdx.json", "spdx.json", "syft.json")) 58 | 59 | for _, format := range formats { 60 | content, err := io.ReadAll(format.Content) 61 | Expect(err).NotTo(HaveOccurred()) 62 | Expect(content).NotTo(BeEmpty()) 63 | } 64 | }) 65 | }) 66 | } 67 | -------------------------------------------------------------------------------- /sbom/init_test.go: -------------------------------------------------------------------------------- 1 | package sbom_test 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/onsi/gomega/format" 7 | "github.com/sclevine/spec" 8 | "github.com/sclevine/spec/report" 9 | ) 10 | 11 | func TestUnitSBOM(t *testing.T) { 12 | format.MaxLength = 0 13 | 14 | suite := spec.New("sbom", spec.Report(report.Terminal{})) 15 | suite("Formatter", testFormatter) 16 | suite("FormattedReader", testFormattedReader) 17 | suite("SBOM", testSBOM) 18 | suite.Run(t) 19 | } 20 | -------------------------------------------------------------------------------- /sbom/sbom.go: -------------------------------------------------------------------------------- 1 | // Package sbom implements standardized SBoM tooling that allows multiple SBoM 2 | // formats to be generated from the same scanning information. 3 | package sbom 4 | 5 | import ( 6 | "context" 7 | "fmt" 8 | "os" 9 | 10 | "github.com/anchore/syft/syft" 11 | "github.com/anchore/syft/syft/cpe" 12 | "github.com/anchore/syft/syft/pkg" 13 | "github.com/anchore/syft/syft/pkg/cataloger/javascript" 14 | "github.com/anchore/syft/syft/sbom" 15 | "github.com/anchore/syft/syft/source" 16 | "github.com/paketo-buildpacks/packit/v2/postal" 17 | ) 18 | 19 | // UnknownCPE is a Common Platform Enumeration (CPE) that uses the NA (Not 20 | // applicable) logical operator for all components of its name. It is designed 21 | // not to match with other CPEs, to avoid false positive CPE matches. 22 | const UnknownCPE = "cpe:2.3:-:-:-:-:-:-:-:-:-:-:-" 23 | 24 | // SBOM holds the internal representation of the generated software 25 | // bill-of-materials. This type can be combined with a FormattedReader to 26 | // output the SBoM in a number of file formats. 27 | type SBOM struct { 28 | syft sbom.SBOM 29 | } 30 | 31 | func NewSBOM(syft sbom.SBOM) SBOM { 32 | return SBOM{syft: syft} 33 | } 34 | 35 | // Generate returns a populated SBOM given a path to a directory to scan. 36 | func Generate(path string) (SBOM, error) { 37 | ctx := context.Background() 38 | 39 | _, err := os.Stat(path) 40 | if err != nil { 41 | return SBOM{}, err 42 | } 43 | 44 | src, err := syft.GetSource(ctx, path, nil) 45 | if err != nil { 46 | return SBOM{}, nil 47 | } 48 | 49 | config := syft.DefaultCreateSBOMConfig() 50 | config.Packages.JavaScript = javascript.DefaultCatalogerConfig().WithIncludeDevDependencies(true) // included for compatibility reasons 51 | 52 | bom, err := syft.CreateSBOM(ctx, src, config) 53 | if err != nil { 54 | return SBOM{}, nil 55 | } 56 | 57 | return SBOM{ 58 | syft: *bom, 59 | }, nil 60 | } 61 | 62 | // GenerateFromDependency returns a populated SBOM given a postal.Dependency 63 | // and the directory path where the dependency will be located within the 64 | // application image. 65 | 66 | // nolint Ignore SA1019, informed usage of deprecated package 67 | func GenerateFromDependency(dependency postal.Dependency, path string) (SBOM, error) { 68 | 69 | //nolint Ignore SA1019, informed usage of deprecated package 70 | if dependency.CPE == "" { 71 | dependency.CPE = UnknownCPE 72 | } 73 | if len(dependency.CPEs) == 0 { 74 | //nolint Ignore SA1019, informed usage of deprecated package 75 | dependency.CPEs = []string{dependency.CPE} 76 | } 77 | 78 | var cpes []cpe.CPE 79 | for _, cpeString := range dependency.CPEs { 80 | cpe, err := cpe.New(cpeString, cpe.DeclaredSource) 81 | if err != nil { 82 | return SBOM{}, err 83 | } 84 | cpes = append(cpes, cpe) 85 | } 86 | 87 | licenses := pkg.NewLicenseSet() 88 | for _, license := range dependency.Licenses { 89 | licenses.Add(pkg.NewLicense(license)) 90 | } 91 | 92 | catalog := pkg.NewCollection(pkg.Package{ 93 | Name: dependency.Name, 94 | Version: dependency.Version, 95 | Licenses: licenses, 96 | CPEs: cpes, 97 | PURL: dependency.PURL, 98 | }) 99 | 100 | return SBOM{ 101 | syft: sbom.SBOM{ 102 | Artifacts: sbom.Artifacts{ 103 | Packages: catalog, 104 | }, 105 | Source: source.Description{ 106 | Metadata: source.DirectoryMetadata{ 107 | Path: path, 108 | }, 109 | }, 110 | }, 111 | }, nil 112 | } 113 | 114 | // InFormats returns a Formatter containing mappings for the given Formats. 115 | func (s SBOM) InFormats(mediaTypes ...string) (Formatter, error) { 116 | var fs []sbom.FormatID 117 | for _, m := range mediaTypes { 118 | format, err := sbomFormatByMediaType(m) 119 | if err != nil { 120 | return Formatter{}, err 121 | } 122 | 123 | if format.Extension() == "" { 124 | return Formatter{}, fmt.Errorf("unable to determine file extension for SBOM format '%s'", format.ID()) 125 | } 126 | 127 | fs = append(fs, sbom.FormatID(fmt.Sprintf("%s@%s", format.ID(), format.Version()))) 128 | } 129 | 130 | return Formatter{sbom: s, formatIDs: fs}, nil 131 | } 132 | -------------------------------------------------------------------------------- /sbom/sbom_format.go: -------------------------------------------------------------------------------- 1 | package sbom 2 | 3 | import ( 4 | "fmt" 5 | "mime" 6 | 7 | "github.com/anchore/syft/syft/format" 8 | syftsbom "github.com/anchore/syft/syft/sbom" 9 | ) 10 | 11 | var encoderCollection = format.NewEncoderCollection(format.Encoders()...) 12 | 13 | var cyclonedxFormats map[string]syftsbom.FormatID = map[string]syftsbom.FormatID{ 14 | "default": CycloneDX13, 15 | "1.3": CycloneDX13, 16 | "1.4": CycloneDX14, 17 | } 18 | 19 | var spdxFormats map[string]syftsbom.FormatID = map[string]syftsbom.FormatID{ 20 | "default": SPDX22, 21 | "2.2": SPDX22, 22 | } 23 | 24 | // An experimental type added to support more SBOM formats 25 | // It extends the Syft sbom.Format interface 26 | type sbomFormat struct { 27 | syftsbom.FormatEncoder 28 | } 29 | 30 | func newSBOMFormat(format syftsbom.FormatEncoder) sbomFormat { 31 | return sbomFormat{ 32 | FormatEncoder: format, 33 | } 34 | } 35 | 36 | func (f sbomFormat) Extension() string { 37 | switch f.ID() { 38 | case CycloneLatest, CycloneDX13, CycloneDX14: 39 | return "cdx.json" 40 | case SPDXLatest, SPDX22: 41 | return "spdx.json" 42 | case SyftLatest: 43 | return "syft.json" 44 | default: 45 | return "" 46 | } 47 | } 48 | 49 | func sbomFormatByMediaType(mediaType string) (sbomFormat, error) { 50 | baseType, params, err := mime.ParseMediaType(mediaType) 51 | if err != nil { 52 | return sbomFormat{}, fmt.Errorf("failed to parse SBOM media type: %w", err) 53 | } 54 | // TODO: semver version parsing? 55 | version, ok := params["version"] 56 | if !ok { 57 | version = "default" 58 | } 59 | var selected syftsbom.FormatID 60 | switch baseType { 61 | case CycloneDXFormat: 62 | selected = cyclonedxFormats[version] 63 | case SPDXFormat: 64 | selected = spdxFormats[version] 65 | case SyftFormat: 66 | selected = SyftLatest 67 | default: 68 | return sbomFormat{}, fmt.Errorf("unsupported SBOM format: '%s'", mediaType) 69 | } 70 | 71 | if selected == syftsbom.FormatID("") { 72 | return sbomFormat{}, fmt.Errorf("version '%s' is not supported for SBOM format '%s'", version, baseType) 73 | } 74 | return sbomFormatByID(selected) 75 | } 76 | 77 | func sbomFormatByID(id syftsbom.FormatID) (sbomFormat, error) { 78 | format := encoderCollection.GetByString(id.String()) 79 | if format == nil { 80 | return sbomFormat{}, fmt.Errorf("'%s' is not a valid SBOM format identifier", id) 81 | } 82 | return newSBOMFormat(format), nil 83 | } 84 | -------------------------------------------------------------------------------- /sbom/sbom_outputs_test.go: -------------------------------------------------------------------------------- 1 | package sbom_test 2 | 3 | import "time" 4 | 5 | /* A set of structs that are used to unmarshal SBOM JSON output in tests */ 6 | 7 | type license struct { 8 | License struct { 9 | ID string `json:"id"` 10 | } `json:"license"` 11 | } 12 | 13 | type component struct { 14 | Type string `json:"type"` 15 | Name string `json:"name"` 16 | Version string `json:"version"` 17 | Licenses []license `json:"licenses"` 18 | PURL string `json:"purl"` 19 | } 20 | 21 | type cdxOutput struct { 22 | BOMFormat string `json:"bomFormat"` 23 | SpecVersion string `json:"specVersion"` 24 | SerialNumber string `json:"serialNumber"` 25 | Metadata struct { 26 | Timestamp string `json:"timestamp"` 27 | Component struct { 28 | Type string `json:"type"` 29 | Name string `json:"name"` 30 | } `json:"component"` 31 | } `json:"metadata"` 32 | Components []component `json:"components"` 33 | } 34 | 35 | type artifact struct { 36 | Name string `json:"name"` 37 | Version string `json:"version"` 38 | Licenses []syftLicense `json:"licenses"` 39 | CPEs []cpe `json:"cpes"` 40 | PURL string `json:"purl"` 41 | } 42 | 43 | type syftLicense struct { 44 | Value string `json:"value"` 45 | } 46 | 47 | type cpe struct { 48 | CPE string `json:"cpe"` 49 | Source string `json:"source"` 50 | } 51 | 52 | type syftOutput struct { 53 | Artifacts []artifact `json:"artifacts"` 54 | Source struct { 55 | Type string `json:"type"` 56 | Metadata metadata `json:"metadata"` 57 | } `json:"source"` 58 | Schema struct { 59 | Version string `json:"version"` 60 | } `json:"schema"` 61 | } 62 | 63 | type metadata struct { 64 | Path string `json:"path"` 65 | } 66 | 67 | type externalRef struct { 68 | Category string `json:"referenceCategory"` 69 | Locator string `json:"referenceLocator"` 70 | Type string `json:"referenceType"` 71 | } 72 | 73 | type pkg struct { 74 | ExternalRefs []externalRef `json:"externalRefs"` 75 | LicenseConcluded string `json:"licenseConcluded"` 76 | LicenseDeclared string `json:"licenseDeclared"` 77 | Name string `json:"name"` 78 | Version string `json:"versionInfo"` 79 | } 80 | 81 | type spdxOutput struct { 82 | Packages []pkg `json:"packages"` 83 | SPDXVersion string `json:"spdxVersion"` 84 | DocumentNamespace string `json:"documentNamespace"` 85 | CreationInfo struct { 86 | Created time.Time `json:"created"` 87 | } `json:"creationInfo"` 88 | } 89 | -------------------------------------------------------------------------------- /sbom/testdata/package-lock.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "npm-lock", 3 | "version": "1.0.0", 4 | "lockfileVersion": 1, 5 | "requires": true, 6 | "dependencies": { 7 | "collapse-white-space": { 8 | "version": "2.0.0", 9 | "resolved": "https://registry.npmjs.org/collapse-white-space/-/collapse-white-space-2.0.0.tgz", 10 | "integrity": "sha512-eh9krktAIMDL0KHuN7WTBJ/0PMv8KUvfQRBkIlGmW61idRM2DJjgd1qXEPr4wyk2PimZZeNww3RVYo6CMvDGlg==" 11 | }, 12 | "end-of-stream": { 13 | "version": "1.4.4", 14 | "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.4.tgz", 15 | "integrity": "sha512-+uw1inIHVPQoaVuHzRyXd21icM+cnt4CzD5rW+NC1wjOUSTOs+Te7FOv7AhN7vS9x/oIyhLP5PR1H+phQAHu5Q==", 16 | "dev": true, 17 | "requires": { 18 | "once": "^1.4.0" 19 | } 20 | }, 21 | "insert-css": { 22 | "version": "2.0.0", 23 | "resolved": "https://registry.npmjs.org/insert-css/-/insert-css-2.0.0.tgz", 24 | "integrity": "sha1-610Ql7dUL0x56jBg067gfQU4gPQ=" 25 | }, 26 | "once": { 27 | "version": "1.4.0", 28 | "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", 29 | "integrity": "sha1-WDsap3WWHUsROsF9nFC6753Xa9E=", 30 | "dev": true, 31 | "requires": { 32 | "wrappy": "1" 33 | } 34 | }, 35 | "pump": { 36 | "version": "3.0.0", 37 | "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.0.tgz", 38 | "integrity": "sha512-LwZy+p3SFs1Pytd/jYct4wpv49HiYCqd9Rlc5ZVdk0V+8Yzv6jR5Blk3TRmPL1ft69TxP0IMZGJ+WPFU2BFhww==", 39 | "dev": true, 40 | "requires": { 41 | "end-of-stream": "^1.1.0", 42 | "once": "^1.3.1" 43 | } 44 | }, 45 | "wrappy": { 46 | "version": "1.0.2", 47 | "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", 48 | "integrity": "sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8=", 49 | "dev": true 50 | } 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /sbom_formatter.go: -------------------------------------------------------------------------------- 1 | package packit 2 | 3 | import "io" 4 | 5 | // SBOMFormat represents the mapping of a formatted SBOM content to its file 6 | // extension on the filesystem. 7 | type SBOMFormat struct { 8 | Extension string 9 | Content io.Reader 10 | } 11 | 12 | // SBOMFormatter defines the interface for types capable of generating 13 | // formatted SBoMs. 14 | type SBOMFormatter interface { 15 | Formats() []SBOMFormat 16 | } 17 | 18 | // SBOMFormats implements the SBOMFormatter interface by wrapping a slice of 19 | // SBOMFormat instances. This allows for quick, inline instantiation of a type 20 | // that conforms to the SBOMFormatter interface. 21 | type SBOMFormats []SBOMFormat 22 | 23 | // Formats returns the slice of SBOMFormat instances wrapped by the SBOMFormats 24 | // type. 25 | func (f SBOMFormats) Formats() []SBOMFormat { 26 | return f 27 | } 28 | -------------------------------------------------------------------------------- /sbomgen/fakes/executable.go: -------------------------------------------------------------------------------- 1 | package fakes 2 | 3 | import ( 4 | "sync" 5 | 6 | "github.com/paketo-buildpacks/packit/v2/pexec" 7 | ) 8 | 9 | type Executable struct { 10 | ExecuteCall struct { 11 | mutex sync.Mutex 12 | CallCount int 13 | Receives struct { 14 | Execution pexec.Execution 15 | } 16 | Returns struct { 17 | Err error 18 | } 19 | Stub func(pexec.Execution) error 20 | } 21 | } 22 | 23 | func (f *Executable) Execute(param1 pexec.Execution) error { 24 | f.ExecuteCall.mutex.Lock() 25 | defer f.ExecuteCall.mutex.Unlock() 26 | f.ExecuteCall.CallCount++ 27 | f.ExecuteCall.Receives.Execution = param1 28 | if f.ExecuteCall.Stub != nil { 29 | return f.ExecuteCall.Stub(param1) 30 | } 31 | return f.ExecuteCall.Returns.Err 32 | } 33 | -------------------------------------------------------------------------------- /sbomgen/formats.go: -------------------------------------------------------------------------------- 1 | package sbomgen 2 | 3 | import ( 4 | "fmt" 5 | "mime" 6 | "strings" 7 | ) 8 | 9 | const ( 10 | CycloneDXFormat = "application/vnd.cyclonedx+json" 11 | SPDXFormat = "application/spdx+json" 12 | SyftFormat = "application/vnd.syft+json" 13 | ) 14 | 15 | // Format is the type declaration for the supported SBoM output formats. 16 | type Format string 17 | 18 | // Extension outputs the expected file extension for a given Format. 19 | // packit allows CycloneDX and SPDX mediatypes to have an optional 20 | // version suffix. e.g. "application/vnd.cyclonedx+json;version=1.4" 21 | // The version suffix is not allowed for the syft mediatype as the 22 | // syft tooling does not support providing a version for this mediatype. 23 | func (f Format) Extension() (string, error) { 24 | switch { 25 | case strings.HasPrefix(string(f), CycloneDXFormat): 26 | return "cdx.json", nil 27 | case strings.HasPrefix(string(f), SPDXFormat): 28 | return "spdx.json", nil 29 | case f == SyftFormat: 30 | return "syft.json", nil 31 | default: 32 | return "", fmt.Errorf("Unknown mediatype %s", f) 33 | } 34 | } 35 | 36 | // Extracts optional version. This usually derives from the "sbom-formats" 37 | // field used by packit-based buildpacks (@packit.SBOMFormats). e.g. 38 | // "application/vnd.cyclonedx+json;version=1.4" -> "1.4" See 39 | // github.com/paketo-buildpacks/packit/issues/302 40 | func (f Format) VersionParam() (string, error) { 41 | _, params, err := mime.ParseMediaType(string(f)) 42 | if err != nil { 43 | return "", fmt.Errorf("failed to parse SBOM mediatype. Expected [;version=], Got %s: %w", f, err) 44 | } 45 | 46 | version, ok := params["version"] 47 | if !ok { 48 | return "", nil 49 | } 50 | return version, nil 51 | } 52 | -------------------------------------------------------------------------------- /sbomgen/formats_test.go: -------------------------------------------------------------------------------- 1 | package sbomgen_test 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/paketo-buildpacks/packit/v2/sbomgen" 7 | "github.com/sclevine/spec" 8 | 9 | . "github.com/onsi/gomega" 10 | ) 11 | 12 | func testFormats(t *testing.T, context spec.G, it spec.S) { 13 | var Expect = NewWithT(t).Expect 14 | var f sbomgen.Format 15 | 16 | context("Formats", func() { 17 | context("no version param", func() { 18 | it("gets the right mediatype extension and version", func() { 19 | f = sbomgen.CycloneDXFormat 20 | ext, err := f.Extension() 21 | Expect(err).NotTo(HaveOccurred()) 22 | Expect(ext).To(Equal("cdx.json")) 23 | Expect(f.VersionParam()).To(Equal("")) 24 | }) 25 | }) 26 | 27 | context("with version param", func() { 28 | it("gets the right mediatype extension and version", func() { 29 | f = sbomgen.SPDXFormat + ";version=9.8.7" 30 | ext, err := f.Extension() 31 | Expect(err).NotTo(HaveOccurred()) 32 | Expect(ext).To(Equal("spdx.json")) 33 | Expect(f.VersionParam()).To(Equal("9.8.7")) 34 | }) 35 | context("Syft mediatype with version returns empty", func() { 36 | it("returns error", func() { 37 | f = sbomgen.SyftFormat + ";version=9.8.7" 38 | ext, err := f.Extension() 39 | Expect(err).To(MatchError(ContainSubstring("Unknown mediatype application/vnd.syft+json;version=9.8.7"))) 40 | Expect(ext).To(Equal("")) 41 | }) 42 | }) 43 | }) 44 | }) 45 | } 46 | -------------------------------------------------------------------------------- /sbomgen/init_test.go: -------------------------------------------------------------------------------- 1 | package sbomgen_test 2 | 3 | import ( 4 | "testing" 5 | "time" 6 | 7 | "github.com/onsi/gomega/format" 8 | "github.com/sclevine/spec" 9 | "github.com/sclevine/spec/report" 10 | ) 11 | 12 | func TestUnitSBOM(t *testing.T) { 13 | format.MaxLength = 0 14 | 15 | suite := spec.New("sbomgen", spec.Report(report.Terminal{})) 16 | suite("Formats", testFormats) 17 | suite("SyftCLIScanner", testSyftCLIScanner) 18 | suite.Run(t) 19 | } 20 | 21 | type externalRef struct { 22 | Category string `json:"referenceCategory"` 23 | Locator string `json:"referenceLocator"` 24 | Type string `json:"referenceType"` 25 | } 26 | 27 | type pkg struct { 28 | ExternalRefs []externalRef `json:"externalRefs"` 29 | LicenseConcluded string `json:"licenseConcluded"` 30 | LicenseDeclared string `json:"licenseDeclared"` 31 | Name string `json:"name"` 32 | Version string `json:"versionInfo"` 33 | } 34 | 35 | type spdxOutput struct { 36 | Packages []pkg `json:"packages"` 37 | SPDXVersion string `json:"spdxVersion"` 38 | DocumentNamespace string `json:"documentNamespace"` 39 | CreationInfo struct { 40 | Created time.Time `json:"created"` 41 | } `json:"creationInfo"` 42 | } 43 | -------------------------------------------------------------------------------- /scribe/color.go: -------------------------------------------------------------------------------- 1 | package scribe 2 | 3 | import ( 4 | "strconv" 5 | ) 6 | 7 | // A set of common text colors. 8 | var ( 9 | BlackColor = NewColor(false, 0, -1) 10 | RedColor = NewColor(false, 1, -1) 11 | GreenColor = NewColor(false, 2, -1) 12 | YellowColor = NewColor(false, 3, -1) 13 | BlueColor = NewColor(false, 4, -1) 14 | MagentaColor = NewColor(false, 5, -1) 15 | CyanColor = NewColor(false, 6, -1) 16 | WhiteColor = NewColor(false, 7, -1) 17 | GrayColor = NewColor(false, 244, -1) 18 | ) 19 | 20 | // A Color is a function that takes a string as an input and returns a string 21 | // with the proper terminal graphic commands. 22 | type Color func(message string) string 23 | 24 | // NewColor returns a Color function that will make text bold based on the 25 | // boolean value of bold and set the text foreground and background, using fg 26 | // and bg respectively, to any color in the 8 bit color space. 27 | func NewColor(bold bool, fg, bg int) Color { 28 | return func(message string) string { 29 | prefix := "\x1b[" 30 | if bold { 31 | prefix = prefix + "1" 32 | } else { 33 | prefix = prefix + "0" 34 | } 35 | 36 | if fg >= 0 { 37 | prefix = prefix + ";38;5;" + strconv.Itoa(fg) 38 | } 39 | 40 | if bg >= 0 { 41 | prefix = prefix + ";48;5;" + strconv.Itoa(bg) 42 | } 43 | 44 | prefix = prefix + "m" 45 | suffix := "\x1b[0m" 46 | 47 | return prefix + message + suffix 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /scribe/color_test.go: -------------------------------------------------------------------------------- 1 | package scribe_test 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/paketo-buildpacks/packit/v2/scribe" 7 | "github.com/sclevine/spec" 8 | 9 | . "github.com/onsi/gomega" 10 | ) 11 | 12 | func testColor(t *testing.T, context spec.G, it spec.S) { 13 | var Expect = NewWithT(t).Expect 14 | 15 | it("returns a function that wraps a string in color codes", func() { 16 | redFgColor := scribe.NewColor(false, 1, -1) 17 | Expect(redFgColor("some-text")).To(Equal("\x1b[0;38;5;1msome-text\x1b[0m")) 18 | 19 | blueBgColor := scribe.NewColor(false, -1, 4) 20 | Expect(blueBgColor("some-text")).To(Equal("\x1b[0;48;5;4msome-text\x1b[0m")) 21 | 22 | magentaBoldFgColor := scribe.NewColor(true, 5, -1) 23 | Expect(magentaBoldFgColor("some-text")).To(Equal("\x1b[1;38;5;5msome-text\x1b[0m")) 24 | 25 | mixedFgBgColor := scribe.NewColor(false, 3, 244) 26 | Expect(mixedFgBgColor("some-text")).To(Equal("\x1b[0;38;5;3;48;5;244msome-text\x1b[0m")) 27 | }) 28 | 29 | context("BlackColor", func() { 30 | it("returns a function that wraps a string in black color codes", func() { 31 | Expect(scribe.BlackColor("some-text")).To(Equal("\x1b[0;38;5;0msome-text\x1b[0m")) 32 | }) 33 | }) 34 | 35 | context("RedColor", func() { 36 | it("returns a function that wraps a string in red color codes", func() { 37 | Expect(scribe.RedColor("some-text")).To(Equal("\x1b[0;38;5;1msome-text\x1b[0m")) 38 | }) 39 | }) 40 | 41 | context("GreenColor", func() { 42 | it("returns a function that wraps a string in green color codes", func() { 43 | Expect(scribe.GreenColor("some-text")).To(Equal("\x1b[0;38;5;2msome-text\x1b[0m")) 44 | }) 45 | }) 46 | 47 | context("YellowColor", func() { 48 | it("returns a function that wraps a string in yellow color codes", func() { 49 | Expect(scribe.YellowColor("some-text")).To(Equal("\x1b[0;38;5;3msome-text\x1b[0m")) 50 | }) 51 | }) 52 | 53 | context("BlueColor", func() { 54 | it("returns a function that wraps a string in blue color codes", func() { 55 | Expect(scribe.BlueColor("some-text")).To(Equal("\x1b[0;38;5;4msome-text\x1b[0m")) 56 | }) 57 | }) 58 | 59 | context("MagentaColor", func() { 60 | it("returns a function that wraps a string in magenta color codes", func() { 61 | Expect(scribe.MagentaColor("some-text")).To(Equal("\x1b[0;38;5;5msome-text\x1b[0m")) 62 | }) 63 | }) 64 | 65 | context("CyanColor", func() { 66 | it("returns a function that wraps a string in cyan color codes", func() { 67 | Expect(scribe.CyanColor("some-text")).To(Equal("\x1b[0;38;5;6msome-text\x1b[0m")) 68 | }) 69 | }) 70 | 71 | context("WhiteColor", func() { 72 | it("returns a function that wraps a string in white color codes", func() { 73 | Expect(scribe.WhiteColor("some-text")).To(Equal("\x1b[0;38;5;7msome-text\x1b[0m")) 74 | }) 75 | }) 76 | 77 | context("GrayColor", func() { 78 | it("returns a function that wraps a string in gray color codes", func() { 79 | Expect(scribe.GrayColor("some-text")).To(Equal("\x1b[0;38;5;244msome-text\x1b[0m")) 80 | }) 81 | }) 82 | } 83 | -------------------------------------------------------------------------------- /scribe/formatted_list.go: -------------------------------------------------------------------------------- 1 | package scribe 2 | 3 | import ( 4 | "sort" 5 | "strings" 6 | ) 7 | 8 | // A FormattedList is a wrapper for []string to extend functionality. 9 | type FormattedList []string 10 | 11 | // Sorts the FormattedList alphabetically and then prints each item on its own 12 | // line. 13 | func (l FormattedList) String() string { 14 | sort.Strings(l) 15 | 16 | var builder strings.Builder 17 | for i, elem := range l { 18 | builder.WriteString(elem) 19 | if i < len(l)-1 { 20 | builder.WriteRune('\n') 21 | } 22 | } 23 | 24 | return builder.String() 25 | } 26 | -------------------------------------------------------------------------------- /scribe/formatted_list_test.go: -------------------------------------------------------------------------------- 1 | package scribe_test 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/paketo-buildpacks/packit/v2/scribe" 7 | "github.com/sclevine/spec" 8 | 9 | . "github.com/onsi/gomega" 10 | ) 11 | 12 | func testFormattedList(t *testing.T, context spec.G, it spec.S) { 13 | var Expect = NewWithT(t).Expect 14 | 15 | context("String", func() { 16 | it("returns a formatted string representation of the list", func() { 17 | Expect(scribe.FormattedList{ 18 | "third", 19 | "first", 20 | "second", 21 | }.String()).To(Equal("first\nsecond\nthird")) 22 | }) 23 | }) 24 | } 25 | -------------------------------------------------------------------------------- /scribe/formatted_map.go: -------------------------------------------------------------------------------- 1 | package scribe 2 | 3 | import ( 4 | "fmt" 5 | "sort" 6 | "strings" 7 | ) 8 | 9 | // A FormattedMap is a wrapper for map[string]interface{} to extend functionality. 10 | type FormattedMap map[string]interface{} 11 | 12 | // Sorts all of the keys in the FormattedMap alphabetically and then constructs 13 | // a padded table. 14 | func (m FormattedMap) String() string { 15 | var ( 16 | keys []string 17 | padding int 18 | ) 19 | for key := range m { 20 | if len(key) > padding { 21 | padding = len(key) 22 | } 23 | keys = append(keys, key) 24 | } 25 | 26 | sort.Strings(keys) 27 | 28 | var builder strings.Builder 29 | for _, key := range keys { 30 | value := m[key] 31 | if value == nil { 32 | value = "" 33 | } 34 | 35 | for i := len(key); i < padding; i++ { 36 | key = key + " " 37 | } 38 | 39 | builder.WriteString(fmt.Sprintf("%s -> \"%v\"\n", key, value)) 40 | } 41 | 42 | return strings.TrimSpace(builder.String()) 43 | } 44 | 45 | // NewFormattedMapFromEnvironment take an environment and returns a 46 | // FormattedMap with the appropriate environment variable information added. 47 | func NewFormattedMapFromEnvironment(environment map[string]string) FormattedMap { 48 | envMap := FormattedMap{} 49 | for key, value := range environment { 50 | parts := strings.SplitN(key, ".", 2) 51 | 52 | if len(parts) < 2 { 53 | envMap[key] = value 54 | continue 55 | } 56 | 57 | switch { 58 | case parts[1] == "override" || parts[1] == "default": 59 | envMap[parts[0]] = value 60 | case parts[1] == "prepend": 61 | existingValue, ok := envMap[parts[0]] 62 | if !ok { 63 | existingValue = "$" + parts[0] 64 | } 65 | envMap[parts[0]] = strings.Join([]string{value, fmt.Sprintf("%v", existingValue)}, environment[parts[0]+".delim"]) 66 | case parts[1] == "append": 67 | existingValue, ok := envMap[parts[0]] 68 | if !ok { 69 | existingValue = "$" + parts[0] 70 | } 71 | envMap[parts[0]] = strings.Join([]string{fmt.Sprintf("%v", existingValue), value}, environment[parts[0]+".delim"]) 72 | } 73 | } 74 | 75 | return envMap 76 | } 77 | -------------------------------------------------------------------------------- /scribe/formatted_map_test.go: -------------------------------------------------------------------------------- 1 | package scribe_test 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/paketo-buildpacks/packit/v2" 7 | "github.com/paketo-buildpacks/packit/v2/scribe" 8 | "github.com/sclevine/spec" 9 | 10 | . "github.com/onsi/gomega" 11 | ) 12 | 13 | func testFormattedMap(t *testing.T, context spec.G, it spec.S) { 14 | var Expect = NewWithT(t).Expect 15 | 16 | context("String", func() { 17 | it("returns a formatted string representation of the map", func() { 18 | Expect(scribe.FormattedMap{ 19 | "third": 3, 20 | "first": 1, 21 | "second": 2, 22 | }.String()).To(Equal("first -> \"1\"\nsecond -> \"2\"\nthird -> \"3\"")) 23 | }) 24 | }) 25 | 26 | context("NewFormattedMapFromEnvironment", func() { 27 | context("for all packit env var operations", func() { 28 | it("prints the env in a well formatted map", func() { 29 | Expect(scribe.NewFormattedMapFromEnvironment(packit.Environment{ 30 | "OVERRIDE.override": "some-value", 31 | "DEFAULT.default": "some-value", 32 | "PREPEND.prepend": "some-value", 33 | "PREPEND.delim": ":", 34 | "APPEND.append": "some-value", 35 | "APPEND.delim": ":", 36 | "BOTH.append": "appended-value", 37 | "BOTH.delim": ":", 38 | "BOTH.prepend": "prepended-value", 39 | })).To(Equal(scribe.FormattedMap{ 40 | "OVERRIDE": "some-value", 41 | "DEFAULT": "some-value", 42 | "PREPEND": "some-value:$PREPEND", 43 | "APPEND": "$APPEND:some-value", 44 | "BOTH": "prepended-value:$BOTH:appended-value", 45 | })) 46 | }) 47 | }) 48 | context("for a standard string map", func() { 49 | it("prints the env in a well formatted map", func() { 50 | Expect(scribe.NewFormattedMapFromEnvironment(map[string]string{ 51 | "SOME_ENV_VAR": "some-value", 52 | "SOME_OTHER_ENV_VAR": "some-other-value", 53 | })).To(Equal(scribe.FormattedMap{ 54 | "SOME_ENV_VAR": "some-value", 55 | "SOME_OTHER_ENV_VAR": "some-other-value", 56 | })) 57 | }) 58 | }) 59 | }) 60 | } 61 | -------------------------------------------------------------------------------- /scribe/init_test.go: -------------------------------------------------------------------------------- 1 | package scribe_test 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/sclevine/spec" 7 | "github.com/sclevine/spec/report" 8 | ) 9 | 10 | func TestUnitScribe(t *testing.T) { 11 | suite := spec.New("scribe", spec.Report(report.Terminal{})) 12 | suite("Color", testColor) 13 | suite("Emitter", testEmitter) 14 | suite("FormattedList", testFormattedList) 15 | suite("FormattedMap", testFormattedMap) 16 | suite("Logger", testLogger) 17 | suite("Writer", testWriter) 18 | suite.Run(t) 19 | } 20 | -------------------------------------------------------------------------------- /scribe/logger.go: -------------------------------------------------------------------------------- 1 | package scribe 2 | 3 | import ( 4 | "fmt" 5 | "io" 6 | "strings" 7 | ) 8 | 9 | // A Logger provides a standard logging interface for doing basic low level 10 | // logging tasks as well as debug logging. 11 | type Logger struct { 12 | writer io.Writer 13 | LeveledLogger 14 | Debug LeveledLogger 15 | } 16 | 17 | // NewLogger takes a writer and returns a Logger that writes to the given 18 | // writer. The default writter sends all debug logging to io.Discard. 19 | func NewLogger(writer io.Writer) Logger { 20 | return Logger{ 21 | writer: writer, 22 | LeveledLogger: NewLeveledLogger(writer), 23 | Debug: NewLeveledLogger(io.Discard), 24 | } 25 | } 26 | 27 | // WithLevel takes in a log level string and configures the log level of the 28 | // logger. To enable debug logging the log level must be set to "DEBUG". 29 | func (l Logger) WithLevel(level string) Logger { 30 | switch strings.ToUpper(level) { 31 | case "DEBUG": 32 | return Logger{ 33 | writer: l.writer, 34 | LeveledLogger: NewLeveledLogger(l.writer), 35 | Debug: NewLeveledLogger(l.writer), 36 | } 37 | default: 38 | return Logger{ 39 | writer: l.writer, 40 | LeveledLogger: NewLeveledLogger(l.writer), 41 | Debug: NewLeveledLogger(io.Discard), 42 | } 43 | } 44 | } 45 | 46 | // A LeveledLogger provides a standard interface for basic formatted logging. 47 | type LeveledLogger struct { 48 | TitleWriter io.Writer 49 | ProcessWriter io.Writer 50 | SubprocessWriter io.Writer 51 | ActionWriter io.Writer 52 | DetailWriter io.Writer 53 | SubdetailWriter io.Writer 54 | } 55 | 56 | // NewLeveledLogger takes a writer and returns a LeveledLogger that writes to the given 57 | // writer. 58 | func NewLeveledLogger(writer io.Writer) LeveledLogger { 59 | return LeveledLogger{ 60 | TitleWriter: NewWriter(writer), 61 | ProcessWriter: NewWriter(writer, WithIndent(1)), 62 | SubprocessWriter: NewWriter(writer, WithIndent(2)), 63 | ActionWriter: NewWriter(writer, WithIndent(3)), 64 | DetailWriter: NewWriter(writer, WithIndent(4)), 65 | SubdetailWriter: NewWriter(writer, WithIndent(5)), 66 | } 67 | } 68 | 69 | // Title takes a string and optional formatting, and prints a formatted string 70 | // with zero levels of indentation. 71 | func (l LeveledLogger) Title(format string, v ...interface{}) { 72 | l.printf(l.TitleWriter, format, v...) 73 | } 74 | 75 | // Process takes a string and optional formatting, and prints a formatted string 76 | // with one level of indentation. 77 | func (l LeveledLogger) Process(format string, v ...interface{}) { 78 | l.printf(l.ProcessWriter, format, v...) 79 | } 80 | 81 | // Subprocess takes a string and optional formatting, and prints a formatted string 82 | // with two levels of indentation. 83 | func (l LeveledLogger) Subprocess(format string, v ...interface{}) { 84 | l.printf(l.SubprocessWriter, format, v...) 85 | } 86 | 87 | // Action takes a string and optional formatting, and prints a formatted string 88 | // with three levels of indentation. 89 | func (l LeveledLogger) Action(format string, v ...interface{}) { 90 | l.printf(l.ActionWriter, format, v...) 91 | } 92 | 93 | // Detail takes a string and optional formatting, and prints a formatted string 94 | // with four levels of indentation. 95 | func (l LeveledLogger) Detail(format string, v ...interface{}) { 96 | l.printf(l.DetailWriter, format, v...) 97 | } 98 | 99 | // Subdetail takes a string and optional formatting, and prints a formatted string 100 | // with five levels of indentation. 101 | func (l LeveledLogger) Subdetail(format string, v ...interface{}) { 102 | l.printf(l.SubdetailWriter, format, v...) 103 | } 104 | 105 | // Break inserts a line break in the log output 106 | func (l LeveledLogger) Break() { 107 | l.printf(l.TitleWriter, "\n") 108 | } 109 | 110 | func (l LeveledLogger) printf(writer io.Writer, format string, v ...interface{}) { 111 | if !strings.HasSuffix(format, "\n") { 112 | format = format + "\n" 113 | } 114 | fmt.Fprintf(writer, format, v...) 115 | } 116 | -------------------------------------------------------------------------------- /scribe/scribe.go: -------------------------------------------------------------------------------- 1 | // Package scribe provides a set of interfaces to allow buildpack authors to 2 | // control their logs on varying levels of granularity. This exposes high level 3 | // logging functions with packit specific business logic baked into the logging 4 | // as well as low level functionality that allows an author to build up their 5 | // own functionality using atomic pieces. 6 | package scribe 7 | -------------------------------------------------------------------------------- /scribe/writer.go: -------------------------------------------------------------------------------- 1 | package scribe 2 | 3 | import ( 4 | "bytes" 5 | "io" 6 | ) 7 | 8 | // An Option is a way to configure a writer's format. 9 | type Option func(Writer) Writer 10 | 11 | // WithColor takes a Color and returns an Option which can be passed in while 12 | // creating a new Writer to configure the color of the output of the Writer. 13 | func WithColor(color Color) Option { 14 | return func(l Writer) Writer { 15 | l.color = color 16 | return l 17 | } 18 | } 19 | 20 | // WithIndent takes an indent level and returns an Option which can be passed in 21 | // while creating a new Writer to configure the indentation level of the output 22 | // of the Writer. 23 | func WithIndent(indent int) Option { 24 | return func(l Writer) Writer { 25 | l.indent = indent 26 | return l 27 | } 28 | } 29 | 30 | // WithPrefix takes a prefix string and returns an Option which can be passed 31 | // in while creating a new Writer to configure a prefix to be prepended to the 32 | // output of the Writer. 33 | func WithPrefix(prefix string) Option { 34 | return func(l Writer) Writer { 35 | l.prefix = prefix 36 | return l 37 | } 38 | } 39 | 40 | // A Writer conforms to the io.Writer interface and allows for configuration of 41 | // output from the writter such as the color or indentation through Options. 42 | type Writer struct { 43 | writer io.Writer 44 | color Color 45 | indent int 46 | prefix string 47 | linestart bool 48 | } 49 | 50 | // NewWriter takes a Writer and Options and returns a Writer that will format 51 | // output according to the options given. 52 | func NewWriter(writer io.Writer, options ...Option) *Writer { 53 | w := Writer{writer: writer, linestart: true} 54 | for _, option := range options { 55 | w = option(w) 56 | } 57 | 58 | return &w 59 | } 60 | 61 | // Write takes the given byte array and formats it in accordance with the 62 | // options on the writer and then outputs that formated text. 63 | func (w *Writer) Write(b []byte) (int, error) { 64 | var ( 65 | prefix, suffix []byte 66 | reset = []byte("\r") 67 | newline = []byte("\n") 68 | n = len(b) 69 | ) 70 | 71 | if bytes.HasPrefix(b, reset) { 72 | b = bytes.TrimPrefix(b, reset) 73 | prefix = reset 74 | } 75 | 76 | if bytes.HasSuffix(b, newline) { 77 | b = bytes.TrimSuffix(b, newline) 78 | suffix = newline 79 | } 80 | 81 | lines := bytes.Split(b, newline) 82 | 83 | var indentedLines [][]byte 84 | for index, line := range lines { 85 | if !(index == 0 && !w.linestart) { 86 | line = append([]byte(w.prefix), line...) 87 | 88 | for i := 0; i < w.indent; i++ { 89 | line = append([]byte(" "), line...) 90 | } 91 | } 92 | indentedLines = append(indentedLines, line) 93 | } 94 | 95 | b = bytes.Join(indentedLines, newline) 96 | 97 | if n > 0 { 98 | w.linestart = false 99 | } 100 | 101 | if w.color != nil { 102 | b = []byte(w.color(string(b))) 103 | } 104 | 105 | if prefix != nil { 106 | b = append(prefix, b...) 107 | } 108 | 109 | if suffix != nil { 110 | b = append(b, suffix...) 111 | w.linestart = true 112 | } 113 | 114 | _, err := w.writer.Write(b) 115 | if err != nil { 116 | return n, err 117 | } 118 | 119 | return n, nil 120 | } 121 | -------------------------------------------------------------------------------- /scripts/.util/print.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | set -eu 4 | set -o pipefail 5 | 6 | function util::print::title() { 7 | local blue reset message 8 | blue="\033[0;34m" 9 | reset="\033[0;39m" 10 | message="${1}" 11 | 12 | echo -e "\n${blue}${message}${reset}" >&2 13 | } 14 | 15 | function util::print::info() { 16 | local message 17 | message="${1}" 18 | 19 | echo -e "${message}" >&2 20 | } 21 | 22 | function util::print::error() { 23 | local message red reset 24 | message="${1}" 25 | red="\033[0;31m" 26 | reset="\033[0;39m" 27 | 28 | echo -e "${red}${message}${reset}" >&2 29 | exit 1 30 | } 31 | 32 | function util::print::success() { 33 | local message green reset 34 | message="${1}" 35 | green="\033[0;32m" 36 | reset="\033[0;39m" 37 | 38 | echo -e "${green}${message}${reset}" >&2 39 | exitcode="${2:-0}" 40 | exit "${exitcode}" 41 | } 42 | 43 | function util::print::warn() { 44 | local message yellow reset 45 | message="${1}" 46 | yellow="\033[0;33m" 47 | reset="\033[0;39m" 48 | 49 | echo -e "${yellow}${message}${reset}" >&2 50 | exit 0 51 | } 52 | -------------------------------------------------------------------------------- /scripts/.util/tools.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | set -eu 4 | set -o pipefail 5 | 6 | # shellcheck source=SCRIPTDIR/print.sh 7 | source "$(dirname "${BASH_SOURCE[0]}")/print.sh" 8 | 9 | function util::tools::tests::checkfocus() { 10 | testout="${1}" 11 | if grep -q 'Focused: [1-9]' "${testout}"; then 12 | echo "Detected Focused Test(s) - setting exit code to 197" 13 | rm "${testout}" 14 | util::print::success "** GO Test Succeeded **" 197 15 | fi 16 | rm "${testout}" 17 | } 18 | -------------------------------------------------------------------------------- /scripts/unit.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | set -eu 4 | set -o pipefail 5 | 6 | readonly PROGDIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" 7 | readonly BUILDPACKDIR="$(cd "${PROGDIR}/.." && pwd)" 8 | 9 | # shellcheck source=SCRIPTDIR/.util/tools.sh 10 | source "${PROGDIR}/.util/tools.sh" 11 | 12 | # shellcheck source=SCRIPTDIR/.util/print.sh 13 | source "${PROGDIR}/.util/print.sh" 14 | 15 | function main() { 16 | while [[ "${#}" != 0 ]]; do 17 | case "${1}" in 18 | --help|-h) 19 | shift 1 20 | usage 21 | exit 0 22 | ;; 23 | 24 | "") 25 | # skip if the argument is empty 26 | shift 1 27 | ;; 28 | 29 | *) 30 | util::print::error "unknown argument \"${1}\"" 31 | esac 32 | done 33 | 34 | unit::run 35 | } 36 | 37 | function usage() { 38 | cat <<-USAGE 39 | unit.sh [OPTIONS] 40 | 41 | Runs the unit test suite. 42 | 43 | OPTIONS 44 | --help -h prints the command usage 45 | USAGE 46 | } 47 | 48 | function unit::run() { 49 | util::print::title "Run Library pack Unit and Example Tests" 50 | 51 | testout=$(mktemp) 52 | pushd "${BUILDPACKDIR}" > /dev/null 53 | if go test ./... -v | tee "${testout}"; then 54 | util::tools::tests::checkfocus "${testout}" 55 | util::print::success "** GO Test Succeeded **" 56 | else 57 | util::print::error "** GO Test Failed **" 58 | fi 59 | popd > /dev/null 60 | } 61 | 62 | main "${@:-}" 63 | -------------------------------------------------------------------------------- /servicebindings/entry.go: -------------------------------------------------------------------------------- 1 | package servicebindings 2 | 3 | import ( 4 | "bytes" 5 | "io" 6 | "os" 7 | ) 8 | 9 | // Entry represents the read-only content of a binding entry. 10 | type Entry struct { 11 | path string 12 | file *os.File 13 | value *bytes.Reader 14 | } 15 | 16 | // NewEntry returns a new Entry whose content is given by the file at the provided path. 17 | func NewEntry(path string) *Entry { 18 | return &Entry{ 19 | path: path, 20 | } 21 | } 22 | 23 | // NewWithValue returns a new Entry with predefined value. 24 | func NewWithValue(value []byte) *Entry { 25 | return &Entry{ 26 | value: bytes.NewReader(value), 27 | } 28 | } 29 | 30 | // ReadBytes reads the entire raw content of the entry. There is no need to call Close after calling ReadBytes. 31 | func (e *Entry) ReadBytes() ([]byte, error) { 32 | if e.value != nil { 33 | return io.ReadAll(e.value) 34 | } 35 | return os.ReadFile(e.path) 36 | } 37 | 38 | // ReadString reads the entire content of the entry as a string. There is no need to call Close after calling 39 | // ReadString. 40 | func (e *Entry) ReadString() (string, error) { 41 | var bytes []byte 42 | var err error 43 | 44 | if e.value != nil { 45 | bytes, err = io.ReadAll(e.value) 46 | if err != nil { 47 | return "", err 48 | } 49 | } else { 50 | bytes, err = e.ReadBytes() 51 | if err != nil { 52 | return "", err 53 | } 54 | } 55 | 56 | return string(bytes), nil 57 | } 58 | 59 | // Read reads up to len(b) bytes from the entry. It returns the number of bytes read and any error encountered. At end 60 | // of entry data, Read returns 0, io.EOF. 61 | // Close must be called when all read operations are complete. 62 | func (e *Entry) Read(b []byte) (int, error) { 63 | if e.value != nil { 64 | return e.value.Read(b) 65 | } 66 | 67 | if e.file == nil { 68 | file, err := os.Open(e.path) 69 | if err != nil { 70 | return 0, err 71 | } 72 | e.file = file 73 | } 74 | return e.file.Read(b) 75 | } 76 | 77 | // Close closes the entry and resets it for reading. After calling Close, any subsequent calls to Read will read entry 78 | // data from the beginning. Close may be called on a closed entry without error. 79 | func (e *Entry) Close() error { 80 | defer func() { 81 | e.file = nil 82 | }() 83 | 84 | if e.value != nil { 85 | _, err := e.value.Seek(0, io.SeekStart) 86 | return err 87 | } else if e.file == nil { 88 | return nil 89 | } else { 90 | return e.file.Close() 91 | } 92 | } 93 | -------------------------------------------------------------------------------- /servicebindings/entry_test.go: -------------------------------------------------------------------------------- 1 | package servicebindings_test 2 | 3 | import ( 4 | "io" 5 | "os" 6 | "path/filepath" 7 | "testing" 8 | 9 | "github.com/paketo-buildpacks/packit/v2/servicebindings" 10 | "github.com/sclevine/spec" 11 | 12 | . "github.com/onsi/gomega" 13 | ) 14 | 15 | func testEntry(t *testing.T, context spec.G, it spec.S) { 16 | var ( 17 | Expect = NewWithT(t).Expect 18 | entry *servicebindings.Entry 19 | entryWithValue *servicebindings.Entry 20 | tmpDir string 21 | ) 22 | 23 | it.Before(func() { 24 | var err error 25 | tmpDir, err = os.MkdirTemp("", "entry") 26 | Expect(err).NotTo(HaveOccurred()) 27 | entryPath := filepath.Join(tmpDir, "entry") 28 | Expect(os.WriteFile(entryPath, []byte("some data"), os.ModePerm)).To(Succeed()) 29 | entry = servicebindings.NewEntry(entryPath) 30 | entryWithValue = servicebindings.NewWithValue([]byte("value from env")) 31 | }) 32 | 33 | it.After(func() { 34 | Expect(os.RemoveAll(tmpDir)).To(Succeed()) 35 | }) 36 | 37 | context("ReadBytes", func() { 38 | it("returns the raw bytes of the entry", func() { 39 | Expect(entry.ReadBytes()).To(Equal([]byte("some data"))) 40 | Expect(entryWithValue.ReadBytes()).To(Equal([]byte("value from env"))) 41 | }) 42 | }) 43 | 44 | context("ReadString", func() { 45 | it("returns the string value of the entry", func() { 46 | Expect(entry.ReadString()).To(Equal("some data")) 47 | Expect(entryWithValue.ReadString()).To(Equal("value from env")) 48 | }) 49 | }) 50 | 51 | context("usage as an io.ReadCloser", func() { 52 | it("is assignable to io.ReadCloser", func() { 53 | var _ io.ReadCloser = entry 54 | }) 55 | 56 | it("can be read again after closing", func() { 57 | data, err := io.ReadAll(entry) 58 | Expect(err).NotTo(HaveOccurred()) 59 | Expect(entry.Close()).To(Succeed()) 60 | Expect(data).To(Equal([]byte("some data"))) 61 | 62 | data, err = io.ReadAll(entry) 63 | Expect(err).NotTo(HaveOccurred()) 64 | Expect(entry.Close()).To(Succeed()) 65 | Expect(data).To(Equal([]byte("some data"))) 66 | 67 | data, err = io.ReadAll(entryWithValue) 68 | Expect(err).NotTo(HaveOccurred()) 69 | Expect(entryWithValue.Close()).To(Succeed()) 70 | Expect(data).To(Equal([]byte("value from env"))) 71 | 72 | data, err = io.ReadAll(entryWithValue) 73 | Expect(err).NotTo(HaveOccurred()) 74 | Expect(entryWithValue.Close()).To(Succeed()) 75 | Expect(data).To(Equal([]byte("value from env"))) 76 | }) 77 | 78 | it("can be closed multiple times in a row", func() { 79 | _, err := io.ReadAll(entry) 80 | Expect(err).NotTo(HaveOccurred()) 81 | Expect(entry.Close()).To(Succeed()) 82 | Expect(entry.Close()).To(Succeed()) 83 | 84 | _, err = io.ReadAll(entryWithValue) 85 | Expect(err).NotTo(HaveOccurred()) 86 | Expect(entryWithValue.Close()).To(Succeed()) 87 | Expect(entryWithValue.Close()).To(Succeed()) 88 | }) 89 | 90 | it("can be closed if never read from", func() { 91 | Expect(entry.Close()).To(Succeed()) 92 | Expect(entryWithValue.Close()).To(Succeed()) 93 | }) 94 | }) 95 | } 96 | -------------------------------------------------------------------------------- /servicebindings/init_test.go: -------------------------------------------------------------------------------- 1 | package servicebindings_test 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/sclevine/spec" 7 | "github.com/sclevine/spec/report" 8 | ) 9 | 10 | func TestUnitServiceBindings(t *testing.T) { 11 | suite := spec.New("packit/servicebindings", spec.Report(report.Terminal{})) 12 | suite("Resolver", testResolver) 13 | suite("Entry", testEntry) 14 | suite.Run(t) 15 | } 16 | -------------------------------------------------------------------------------- /servicebindings/servicebinding.go: -------------------------------------------------------------------------------- 1 | // Package servicebindings provides a service for inspecting and retrieving 2 | // data from service binding. 3 | package servicebindings 4 | -------------------------------------------------------------------------------- /servicebindings/testdata/vcap_services.json: -------------------------------------------------------------------------------- 1 | { 2 | "elephantsql-provider": [ 3 | { 4 | "name": "elephantsql-binding-c6c60", 5 | "binding_guid": "44ceb72f-100b-4f50-87a2-7809c8b42b8d", 6 | "binding_name": "elephantsql-binding-c6c60", 7 | "instance_guid": "391308e8-8586-4c42-b464-c7831aa2ad22", 8 | "instance_name": "elephantsql-c6c60", 9 | "label": "elephantsql-type", 10 | "tags": [ 11 | "postgres", 12 | "postgresql", 13 | "relational" 14 | ], 15 | "plan": "turtle", 16 | "credentials": { 17 | "uri": "postgres://exampleuser:examplepass@postgres.example.com:5432/exampleuser", 18 | "int": 1, 19 | "bool": true 20 | }, 21 | "syslog_drain_url": null, 22 | "volume_mounts": [] 23 | } 24 | ], 25 | "sendgrid-provider": [ 26 | { 27 | "name": "mysendgrid", 28 | "binding_guid": "6533b1b6-7916-488d-b286-ca33d3fa0081", 29 | "binding_name": null, 30 | "instance_guid": "8c907d0f-ec0f-44e4-87cf-e23c9ba3925d", 31 | "instance_name": "mysendgrid", 32 | "label": "sendgrid-type", 33 | "tags": [ 34 | "smtp" 35 | ], 36 | "plan": "free", 37 | "credentials": { 38 | "hostname": "smtp.example.com", 39 | "username": "QvsXMbJ3rK", 40 | "password": "HCHMOYluTv" 41 | }, 42 | "syslog_drain_url": null, 43 | "volume_mounts": [] 44 | } 45 | ], 46 | "postgres": [ 47 | { 48 | "name": "postgres", 49 | "label": "postgres", 50 | "plan": "default", 51 | "tags": [ 52 | "postgres" 53 | ], 54 | "binding_guid": "6533b1b6-7916-488d-b286-ca33d3fa0081", 55 | "binding_name": null, 56 | "instance_guid": "8c907d0f-ec0f-44e4-87cf-e23c9ba3925d", 57 | "credentials": { 58 | "username": "foo", 59 | "password": "bar", 60 | "urls": { 61 | "example": "http://example.com" 62 | } 63 | }, 64 | "syslog_drain_url": null, 65 | "volume_mounts": [] 66 | } 67 | ] 68 | } 69 | -------------------------------------------------------------------------------- /slice.go: -------------------------------------------------------------------------------- 1 | package packit 2 | 3 | // Slice represents a layer of the working directory to be exported during the 4 | // export phase. These slices help to optimize data transfer for files that are 5 | // commonly shared across applications. Slices are described in the layers 6 | // section of the buildpack spec: 7 | // https://github.com/buildpacks/spec/blob/main/buildpack.md#layers. The slice 8 | // fields are described in the specification of the launch.toml file: 9 | // https://github.com/buildpacks/spec/blob/main/buildpack.md#launchtoml-toml. 10 | type Slice struct { 11 | Paths []string `toml:"paths"` 12 | } 13 | -------------------------------------------------------------------------------- /vacation/archive.go: -------------------------------------------------------------------------------- 1 | package vacation 2 | 3 | import ( 4 | "bufio" 5 | "fmt" 6 | "io" 7 | 8 | "github.com/gabriel-vasile/mimetype" 9 | ) 10 | 11 | type Decompressor interface { 12 | Decompress(destination string) error 13 | } 14 | 15 | // An Archive decompresses tar, gzip, xz, and bzip2 compressed tar, and zip files from 16 | // an input stream. 17 | type Archive struct { 18 | reader io.Reader 19 | components int 20 | name string 21 | } 22 | 23 | // NewArchive returns a new Archive that reads from inputReader. 24 | func NewArchive(inputReader io.Reader) Archive { 25 | return Archive{ 26 | reader: inputReader, 27 | } 28 | } 29 | 30 | // Decompress reads from Archive, determines the archive type of the input 31 | // stream, and writes files into the destination specified. 32 | // 33 | // Archive decompression will also handle files that are types 34 | // - "application/x-executable" 35 | // - "text/plain; charset=utf-8" 36 | // - "application/jar" 37 | // - "application/octet-stream" 38 | // and write the contents of the input stream to a file name specified by the 39 | // `Archive.WithName()` option in the destination directory. 40 | func (a Archive) Decompress(destination string) error { 41 | // Convert reader into a buffered read so that the header can be peeked to 42 | // determine the type. 43 | bufferedReader := bufio.NewReader(a.reader) 44 | 45 | // The number 3072 is lifted from the mimetype library and the definition of 46 | // the constant at the time of writing this functionality is listed below. 47 | // https://github.com/gabriel-vasile/mimetype/blob/c64c025a7c2d8d45ba57d3cebb50a1dbedb3ed7e/internal/matchers/matchers.go#L6 48 | header, err := bufferedReader.Peek(3072) 49 | if err != nil && err != io.EOF { 50 | return err 51 | } 52 | 53 | mime := mimetype.Detect(header) 54 | 55 | // This switch case is responsible for determining the decompression strategy 56 | var decompressor Decompressor 57 | switch mime.String() { 58 | case "application/x-tar": 59 | decompressor = NewTarArchive(bufferedReader).StripComponents(a.components) 60 | case "application/gzip": 61 | decompressor = NewGzipArchive(bufferedReader).StripComponents(a.components).WithName(a.name) 62 | case "application/x-xz": 63 | decompressor = NewXZArchive(bufferedReader).StripComponents(a.components).WithName(a.name) 64 | case "application/x-bzip2": 65 | decompressor = NewBzip2Archive(bufferedReader).StripComponents(a.components).WithName(a.name) 66 | case "application/zip": 67 | decompressor = NewZipArchive(bufferedReader).StripComponents(a.components) 68 | case "application/x-executable": 69 | decompressor = NewExecutable(bufferedReader).WithName(a.name) 70 | case "text/plain; charset=utf-8", 71 | "application/jar", 72 | "application/octet-stream": 73 | decompressor = NewNopArchive(bufferedReader).WithName(a.name) 74 | default: 75 | return fmt.Errorf("unsupported archive type: %s", mime.String()) 76 | } 77 | 78 | return decompressor.Decompress(destination) 79 | } 80 | 81 | // StripComponents behaves like the --strip-components flag on tar command 82 | // removing the first n levels from the final decompression destination. 83 | // Setting this is a no-op for archive types that do not use --strip-components 84 | // (such as zip). 85 | func (a Archive) StripComponents(components int) Archive { 86 | a.components = components 87 | return a 88 | } 89 | 90 | // WithName provides a way of overriding the name of the file 91 | // that the decompressed file will be copied into. 92 | func (a Archive) WithName(name string) Archive { 93 | a.name = name 94 | return a 95 | } 96 | -------------------------------------------------------------------------------- /vacation/bzip2_archive.go: -------------------------------------------------------------------------------- 1 | package vacation 2 | 3 | import ( 4 | "compress/bzip2" 5 | "io" 6 | ) 7 | 8 | // A Bzip2Archive decompresses bzip2 files from an input stream. 9 | type Bzip2Archive struct { 10 | reader io.Reader 11 | components int 12 | name string 13 | } 14 | 15 | // NewBzip2Archive returns a new Bzip2Archive that reads from inputReader. 16 | func NewBzip2Archive(inputReader io.Reader) Bzip2Archive { 17 | return Bzip2Archive{reader: inputReader} 18 | } 19 | 20 | // Decompress reads from Bzip2Archive and writes files into the destination 21 | // specified. 22 | func (bz Bzip2Archive) Decompress(destination string) error { 23 | return NewArchive(bzip2.NewReader(bz.reader)).WithName(bz.name).StripComponents(bz.components).Decompress(destination) 24 | } 25 | 26 | // StripComponents behaves like the --strip-components flag on tar command 27 | // removing the first n levels from the final decompression destination. 28 | func (bz Bzip2Archive) StripComponents(components int) Bzip2Archive { 29 | bz.components = components 30 | return bz 31 | } 32 | 33 | // WithName provides a way of overriding the name of the file 34 | // that the decompressed file will be copied into. 35 | func (bz Bzip2Archive) WithName(name string) Bzip2Archive { 36 | bz.name = name 37 | return bz 38 | } 39 | -------------------------------------------------------------------------------- /vacation/bzip2_archive_test.go: -------------------------------------------------------------------------------- 1 | package vacation_test 2 | 3 | import ( 4 | "archive/tar" 5 | "bytes" 6 | "fmt" 7 | "os" 8 | "path/filepath" 9 | "testing" 10 | 11 | dsnetBzip2 "github.com/dsnet/compress/bzip2" 12 | "github.com/paketo-buildpacks/packit/v2/vacation" 13 | "github.com/sclevine/spec" 14 | 15 | . "github.com/onsi/gomega" 16 | ) 17 | 18 | func testBzip2Archive(t *testing.T, context spec.G, it spec.S) { 19 | var ( 20 | Expect = NewWithT(t).Expect 21 | ) 22 | 23 | context("Decompress", func() { 24 | var ( 25 | tempDir string 26 | bzip2Archive vacation.Bzip2Archive 27 | ) 28 | 29 | it.Before(func() { 30 | var err error 31 | tempDir, err = os.MkdirTemp("", "vacation") 32 | Expect(err).NotTo(HaveOccurred()) 33 | 34 | buffer := bytes.NewBuffer(nil) 35 | 36 | // Using the dsnet library because the Go compression library does not 37 | // have a writer. There is recent discussion on this issue 38 | // https://github.com/golang/go/issues/4828 to add an encoder. The 39 | // library should be removed once there is a native encoder 40 | bz, err := dsnetBzip2.NewWriter(buffer, nil) 41 | Expect(err).NotTo(HaveOccurred()) 42 | 43 | tw := tar.NewWriter(bz) 44 | 45 | Expect(tw.WriteHeader(&tar.Header{Name: "some-dir", Mode: 0755, Typeflag: tar.TypeDir})).To(Succeed()) 46 | _, err = tw.Write(nil) 47 | Expect(err).NotTo(HaveOccurred()) 48 | 49 | Expect(tw.WriteHeader(&tar.Header{Name: filepath.Join("some-dir", "some-other-dir"), Mode: 0755, Typeflag: tar.TypeDir})).To(Succeed()) 50 | _, err = tw.Write(nil) 51 | Expect(err).NotTo(HaveOccurred()) 52 | 53 | nestedFile := filepath.Join("some-dir", "some-other-dir", "some-file") 54 | Expect(tw.WriteHeader(&tar.Header{Name: nestedFile, Mode: 0755, Size: int64(len(nestedFile))})).To(Succeed()) 55 | _, err = tw.Write([]byte(nestedFile)) 56 | Expect(err).NotTo(HaveOccurred()) 57 | 58 | for _, file := range []string{"first", "second", "third"} { 59 | Expect(tw.WriteHeader(&tar.Header{Name: file, Mode: 0755, Size: int64(len(file))})).To(Succeed()) 60 | _, err = tw.Write([]byte(file)) 61 | Expect(err).NotTo(HaveOccurred()) 62 | } 63 | 64 | Expect(tw.WriteHeader(&tar.Header{Name: "symlink", Mode: 0777, Size: int64(0), Typeflag: tar.TypeSymlink, Linkname: "first"})).To(Succeed()) 65 | _, err = tw.Write([]byte{}) 66 | Expect(err).NotTo(HaveOccurred()) 67 | 68 | Expect(tw.Close()).To(Succeed()) 69 | Expect(bz.Close()).To(Succeed()) 70 | 71 | bzip2Archive = vacation.NewBzip2Archive(bytes.NewReader(buffer.Bytes())) 72 | }) 73 | 74 | it.After(func() { 75 | Expect(os.RemoveAll(tempDir)).To(Succeed()) 76 | }) 77 | 78 | it("unpackages the archive into the path", func() { 79 | var err error 80 | err = bzip2Archive.Decompress(tempDir) 81 | Expect(err).ToNot(HaveOccurred()) 82 | 83 | files, err := filepath.Glob(fmt.Sprintf("%s/*", tempDir)) 84 | Expect(err).NotTo(HaveOccurred()) 85 | Expect(files).To(ConsistOf([]string{ 86 | filepath.Join(tempDir, "first"), 87 | filepath.Join(tempDir, "second"), 88 | filepath.Join(tempDir, "third"), 89 | filepath.Join(tempDir, "some-dir"), 90 | filepath.Join(tempDir, "symlink"), 91 | })) 92 | 93 | info, err := os.Stat(filepath.Join(tempDir, "first")) 94 | Expect(err).NotTo(HaveOccurred()) 95 | Expect(info.Mode()).To(Equal(os.FileMode(0755))) 96 | 97 | Expect(filepath.Join(tempDir, "some-dir", "some-other-dir")).To(BeADirectory()) 98 | Expect(filepath.Join(tempDir, "some-dir", "some-other-dir", "some-file")).To(BeARegularFile()) 99 | 100 | data, err := os.ReadFile(filepath.Join(tempDir, "symlink")) 101 | Expect(err).NotTo(HaveOccurred()) 102 | Expect(data).To(Equal([]byte(`first`))) 103 | }) 104 | 105 | it("unpackages the archive into the path but also strips the first component", func() { 106 | var err error 107 | err = bzip2Archive.StripComponents(1).Decompress(tempDir) 108 | Expect(err).ToNot(HaveOccurred()) 109 | 110 | files, err := filepath.Glob(fmt.Sprintf("%s/*", tempDir)) 111 | Expect(err).NotTo(HaveOccurred()) 112 | Expect(files).To(ConsistOf([]string{ 113 | filepath.Join(tempDir, "some-other-dir"), 114 | })) 115 | 116 | Expect(filepath.Join(tempDir, "some-other-dir")).To(BeADirectory()) 117 | Expect(filepath.Join(tempDir, "some-other-dir", "some-file")).To(BeARegularFile()) 118 | }) 119 | }) 120 | } 121 | -------------------------------------------------------------------------------- /vacation/executable.go: -------------------------------------------------------------------------------- 1 | package vacation 2 | 3 | import ( 4 | "io" 5 | "os" 6 | "path/filepath" 7 | ) 8 | 9 | // An Executable writes an executable files from an input stream to the with a 10 | // file name specified by the option `Executable.WithName()` (or defaults to 11 | // `artifact`) in the destination directory with executable permissions (0755). 12 | type Executable struct { 13 | reader io.Reader 14 | name string 15 | } 16 | 17 | // NewExecutable returns a new Executable that reads from inputReader. 18 | func NewExecutable(inputReader io.Reader) Executable { 19 | return Executable{ 20 | reader: inputReader, 21 | name: "artifact", 22 | } 23 | } 24 | 25 | // Decompress copies the reader contents into the destination specified and 26 | // sets executable permissions. 27 | func (e Executable) Decompress(destination string) error { 28 | file, err := os.Create(filepath.Join(destination, e.name)) 29 | if err != nil { 30 | return err 31 | } 32 | defer file.Close() 33 | 34 | _, err = io.Copy(file, e.reader) 35 | if err != nil { 36 | return err 37 | } 38 | 39 | err = os.Chmod(filepath.Join(destination, e.name), 0755) 40 | if err != nil { 41 | return err 42 | } 43 | 44 | return nil 45 | } 46 | 47 | // WithName provides a way of overriding the name of the file 48 | // that the decompressed file will be copied into. 49 | func (e Executable) WithName(name string) Executable { 50 | if name != "" { 51 | e.name = name 52 | } 53 | return e 54 | } 55 | -------------------------------------------------------------------------------- /vacation/executable_test.go: -------------------------------------------------------------------------------- 1 | package vacation_test 2 | 3 | import ( 4 | "bytes" 5 | "encoding/base64" 6 | "io" 7 | "io/fs" 8 | "os" 9 | "path/filepath" 10 | "testing" 11 | 12 | "github.com/paketo-buildpacks/packit/v2/vacation" 13 | "github.com/sclevine/spec" 14 | 15 | . "github.com/onsi/gomega" 16 | ) 17 | 18 | func testExecutable(t *testing.T, context spec.G, it spec.S) { 19 | var Expect = NewWithT(t).Expect 20 | 21 | context("Decompress", func() { 22 | var ( 23 | archive vacation.Executable 24 | tempDir string 25 | // Encoding of a very small elf executable from https://github.com/mathiasbynens/small 26 | encodedContents = []byte(`f0VMRgEBAQAAAAAAAAAAAAIAAwABAAAAGUDNgCwAAAAAAAAAAAAAADQAIAABAAAAAAAAAABAzYAAQM2ATAAAAEwAAAAFAAAAABAAAA==`) 27 | literalContents []byte 28 | ) 29 | 30 | it.Before(func() { 31 | var err error 32 | tempDir, err = os.MkdirTemp("", "vacation") 33 | Expect(err).NotTo(HaveOccurred()) 34 | 35 | literalContents, err = io.ReadAll(base64.NewDecoder(base64.StdEncoding, bytes.NewBuffer(encodedContents))) 36 | Expect(err).NotTo(HaveOccurred()) 37 | 38 | archive = vacation.NewExecutable(bytes.NewBuffer(literalContents)) 39 | }) 40 | 41 | it.After(func() { 42 | Expect(os.RemoveAll(tempDir)).To(Succeed()) 43 | }) 44 | 45 | context("when passed the reader of an executable file", func() { 46 | it("writes the executable in the destination directory and sets the permissions using a default name", func() { 47 | err := archive.Decompress(tempDir) 48 | Expect(err).NotTo(HaveOccurred()) 49 | 50 | content, err := os.ReadFile(filepath.Join(tempDir, "artifact")) 51 | Expect(err).NotTo(HaveOccurred()) 52 | Expect(content).To(Equal(literalContents)) 53 | 54 | info, err := os.Stat(filepath.Join(tempDir, "artifact")) 55 | Expect(err).NotTo(HaveOccurred()) 56 | Expect(info.Mode()).To(Equal(fs.FileMode(0755))) 57 | }) 58 | 59 | it("writes the executable in the destination directory and sets the permissions using a given name", func() { 60 | err := archive.WithName("executable").Decompress(tempDir) 61 | Expect(err).NotTo(HaveOccurred()) 62 | 63 | content, err := os.ReadFile(filepath.Join(tempDir, "executable")) 64 | Expect(err).NotTo(HaveOccurred()) 65 | Expect(content).To(Equal(literalContents)) 66 | 67 | info, err := os.Stat(filepath.Join(tempDir, "executable")) 68 | Expect(err).NotTo(HaveOccurred()) 69 | Expect(info.Mode()).To(Equal(fs.FileMode(0755))) 70 | }) 71 | }) 72 | 73 | context("failure cases", func() { 74 | context("when the destination file cannot be created", func() { 75 | it("returns an error", func() { 76 | err := archive.Decompress("/no/such/path") 77 | Expect(err).To(MatchError(ContainSubstring("no such file or directory"))) 78 | }) 79 | }) 80 | }) 81 | }) 82 | } 83 | -------------------------------------------------------------------------------- /vacation/gzip_archive.go: -------------------------------------------------------------------------------- 1 | package vacation 2 | 3 | import ( 4 | "compress/gzip" 5 | "fmt" 6 | "io" 7 | ) 8 | 9 | // A GzipArchive decompresses gzipped files from an input stream. 10 | type GzipArchive struct { 11 | reader io.Reader 12 | components int 13 | name string 14 | } 15 | 16 | // NewGzipArchive returns a new GzipArchive that reads from inputReader. 17 | func NewGzipArchive(inputReader io.Reader) GzipArchive { 18 | return GzipArchive{reader: inputReader} 19 | } 20 | 21 | // Decompress reads from GzipArchive and writes files into the destination 22 | // specified. 23 | func (gz GzipArchive) Decompress(destination string) error { 24 | gzr, err := gzip.NewReader(gz.reader) 25 | if err != nil { 26 | return fmt.Errorf("failed to create gzip reader: %w", err) 27 | } 28 | 29 | return NewArchive(gzr).WithName(gz.name).StripComponents(gz.components).Decompress(destination) 30 | } 31 | 32 | // StripComponents behaves like the --strip-components flag on tar command 33 | // removing the first n levels from the final decompression destination. 34 | func (gz GzipArchive) StripComponents(components int) GzipArchive { 35 | gz.components = components 36 | return gz 37 | } 38 | 39 | // WithName provides a way of overriding the name of the file 40 | // that the decompressed file will be copied into. 41 | func (gz GzipArchive) WithName(name string) GzipArchive { 42 | gz.name = name 43 | return gz 44 | } 45 | -------------------------------------------------------------------------------- /vacation/init_test.go: -------------------------------------------------------------------------------- 1 | package vacation_test 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/sclevine/spec" 7 | "github.com/sclevine/spec/report" 8 | ) 9 | 10 | func TestVacation(t *testing.T) { 11 | suite := spec.New("vacation", spec.Report(report.Terminal{})) 12 | suite("Archive", testArchive) 13 | suite("Bzip2Archive", testBzip2Archive) 14 | suite("Executable", testExecutable) 15 | suite("GzipArchive", testGzipArchive) 16 | suite("LinkSorting", testLinkSorting) 17 | suite("NopArchive", testNopArchive) 18 | suite("TarArchive", testTarArchive) 19 | suite("XZArchive", testXZArchive) 20 | suite("ZipArchive", testZipArchive) 21 | suite.Run(t) 22 | } 23 | -------------------------------------------------------------------------------- /vacation/link_sorting.go: -------------------------------------------------------------------------------- 1 | package vacation 2 | 3 | import ( 4 | "fmt" 5 | "path/filepath" 6 | "strings" 7 | ) 8 | 9 | type link struct { 10 | name string 11 | path string 12 | } 13 | 14 | func sortLinks(symlinks []link) ([]link, error) { 15 | // Create a map of all of the symlink names and where they are pointing to to 16 | // act as a quasi-graph 17 | index := map[string]string{} 18 | for _, s := range symlinks { 19 | index[filepath.Clean(s.path)] = s.name 20 | } 21 | 22 | // Check to see if the link name lies on the path of another symlink in 23 | // the table or if it is another symlink in the table 24 | // 25 | // Example: 26 | // path = dir/file 27 | // a-symlink -> dir 28 | // b-symlink -> a-symlink 29 | // c-symlink -> a-symlink/file 30 | shouldSkipLink := func(linkname, linkpath string) bool { 31 | sln := strings.Split(linkname, "/") 32 | for j := 0; j < len(sln); j++ { 33 | if _, ok := index[linknameFullPath(linkpath, filepath.Join(sln[:j+1]...))]; ok { 34 | return true 35 | } 36 | } 37 | return false 38 | } 39 | 40 | // Iterate over the symlink map for every link that is found this ensures 41 | // that all symlinks that can be created will be created and any that are 42 | // left over are cyclically dependent 43 | var links []link 44 | maxIterations := len(index) 45 | for i := 0; i < maxIterations; i++ { 46 | for path, name := range index { 47 | // If there is a match either of the symlink or it is on the path then 48 | // skip the creation of this symlink for now 49 | if shouldSkipLink(name, path) { 50 | continue 51 | } 52 | 53 | links = append(links, link{ 54 | name: name, 55 | path: path, 56 | }) 57 | 58 | // Remove the created symlink from the symlink table so that its 59 | // dependent symlinks can be created in the next iteration 60 | delete(index, path) 61 | break 62 | } 63 | } 64 | 65 | // Check to see if there are any symlinks left in the map which would 66 | // indicate a cyclical dependency 67 | if len(index) > 0 { 68 | return nil, fmt.Errorf("failed: max iterations reached: this link graph contains a cycle") 69 | } 70 | 71 | return links, nil 72 | } 73 | -------------------------------------------------------------------------------- /vacation/nop_archive.go: -------------------------------------------------------------------------------- 1 | package vacation 2 | 3 | import ( 4 | "io" 5 | "os" 6 | "path/filepath" 7 | ) 8 | 9 | // A NopArchive implements the common archive interface, but acts as a no-op, 10 | // simply copying the reader to the destination with a file name specified by 11 | // the option `NopArchive.WithName()` (or defaults to `artifact`) in the 12 | // destination directory. 13 | type NopArchive struct { 14 | reader io.Reader 15 | name string 16 | } 17 | 18 | // NewNopArchive returns a new NopArchive 19 | func NewNopArchive(r io.Reader) NopArchive { 20 | return NopArchive{ 21 | reader: r, 22 | name: "artifact", 23 | } 24 | } 25 | 26 | // Decompress copies the reader contents into the destination specified. 27 | func (na NopArchive) Decompress(destination string) error { 28 | file, err := os.Create(filepath.Join(destination, na.name)) 29 | if err != nil { 30 | return err 31 | } 32 | defer file.Close() 33 | 34 | _, err = io.Copy(file, na.reader) 35 | if err != nil { 36 | return err 37 | } 38 | 39 | return nil 40 | } 41 | 42 | // WithName provides a way of overriding the name of the file 43 | // that the decompressed file will be copied into. 44 | func (na NopArchive) WithName(name string) NopArchive { 45 | if name != "" { 46 | na.name = name 47 | } 48 | return na 49 | } 50 | -------------------------------------------------------------------------------- /vacation/nop_archive_test.go: -------------------------------------------------------------------------------- 1 | package vacation_test 2 | 3 | import ( 4 | "bytes" 5 | "os" 6 | "path/filepath" 7 | "testing" 8 | 9 | "github.com/paketo-buildpacks/packit/v2/vacation" 10 | "github.com/sclevine/spec" 11 | 12 | . "github.com/onsi/gomega" 13 | ) 14 | 15 | func testNopArchive(t *testing.T, context spec.G, it spec.S) { 16 | var Expect = NewWithT(t).Expect 17 | 18 | context("Decompress", func() { 19 | var ( 20 | archive vacation.NopArchive 21 | tempDir string 22 | ) 23 | 24 | it.Before(func() { 25 | var err error 26 | tempDir, err = os.MkdirTemp("", "vacation") 27 | Expect(err).NotTo(HaveOccurred()) 28 | 29 | buffer := bytes.NewBuffer([]byte(`some contents`)) 30 | 31 | archive = vacation.NewNopArchive(buffer) 32 | }) 33 | 34 | it.After(func() { 35 | Expect(os.RemoveAll(tempDir)).To(Succeed()) 36 | }) 37 | 38 | it("copies the contents of the reader to the destination with a default name", func() { 39 | err := archive.Decompress(filepath.Join(tempDir)) 40 | Expect(err).NotTo(HaveOccurred()) 41 | 42 | content, err := os.ReadFile(filepath.Join(tempDir, "artifact")) 43 | Expect(err).NotTo(HaveOccurred()) 44 | Expect(content).To(Equal([]byte(`some contents`))) 45 | }) 46 | 47 | it("copies the contents of the reader to the destination with a given name", func() { 48 | err := archive.WithName("some-file").Decompress(filepath.Join(tempDir)) 49 | Expect(err).NotTo(HaveOccurred()) 50 | 51 | content, err := os.ReadFile(filepath.Join(tempDir, "some-file")) 52 | Expect(err).NotTo(HaveOccurred()) 53 | Expect(content).To(Equal([]byte(`some contents`))) 54 | }) 55 | 56 | context("failure cases", func() { 57 | context("when the destination file cannot be created", func() { 58 | it("returns an error", func() { 59 | err := archive.Decompress("/no/such/path") 60 | Expect(err).To(MatchError(ContainSubstring("no such file or directory"))) 61 | }) 62 | }) 63 | }) 64 | }) 65 | } 66 | -------------------------------------------------------------------------------- /vacation/vacation.go: -------------------------------------------------------------------------------- 1 | // Package vacation provides a set of functions that enable input stream 2 | // decompression logic from several popular decompression formats. This allows 3 | // from decompression from either a file or any other byte stream, which is 4 | // useful for decompressing files that are being downloaded. 5 | package vacation 6 | -------------------------------------------------------------------------------- /vacation/xz_archive.go: -------------------------------------------------------------------------------- 1 | package vacation 2 | 3 | import ( 4 | "fmt" 5 | "io" 6 | 7 | "github.com/ulikunitz/xz" 8 | ) 9 | 10 | // A XZArchive decompresses xz files from an input stream. 11 | type XZArchive struct { 12 | reader io.Reader 13 | components int 14 | name string 15 | } 16 | 17 | // NewXZArchive returns a new XZArchive that reads from inputReader. 18 | func NewXZArchive(inputReader io.Reader) XZArchive { 19 | return XZArchive{reader: inputReader} 20 | } 21 | 22 | // Decompress reads from XZArchive and writes files into the destination 23 | // specified. 24 | func (xzArchive XZArchive) Decompress(destination string) error { 25 | xzr, err := xz.NewReader(xzArchive.reader) 26 | if err != nil { 27 | return fmt.Errorf("failed to create xz reader: %w", err) 28 | } 29 | 30 | return NewArchive(xzr).WithName(xzArchive.name).StripComponents(xzArchive.components).Decompress(destination) 31 | } 32 | 33 | // StripComponents behaves like the --strip-components flag on tar command 34 | // removing the first n levels from the final decompression destination. 35 | func (xzArchive XZArchive) StripComponents(components int) XZArchive { 36 | xzArchive.components = components 37 | return xzArchive 38 | } 39 | 40 | // WithName provides a way of overriding the name of the file 41 | // that the decompressed file will be copied into. 42 | func (xzArchive XZArchive) WithName(name string) XZArchive { 43 | xzArchive.name = name 44 | return xzArchive 45 | } 46 | -------------------------------------------------------------------------------- /vacation/zip_archive.go: -------------------------------------------------------------------------------- 1 | package vacation 2 | 3 | import ( 4 | "archive/zip" 5 | "fmt" 6 | "io" 7 | "os" 8 | "path/filepath" 9 | "strings" 10 | ) 11 | 12 | // A ZipArchive decompresses zip files from an input stream. 13 | type ZipArchive struct { 14 | reader io.Reader 15 | components int 16 | } 17 | 18 | // NewZipArchive returns a new ZipArchive that reads from inputReader. 19 | func NewZipArchive(inputReader io.Reader) ZipArchive { 20 | return ZipArchive{reader: inputReader} 21 | } 22 | 23 | // Decompress reads from ZipArchive and writes files into the destination 24 | // specified. 25 | func (z ZipArchive) Decompress(destination string) error { 26 | 27 | // Use an os.File to buffer the zip contents. This is needed because 28 | // zip.NewReader requires an io.ReaderAt so that it can jump around within 29 | // the file as it decompresses. 30 | buffer, err := os.CreateTemp("", "") 31 | if err != nil { 32 | return err 33 | } 34 | defer os.Remove(buffer.Name()) 35 | 36 | size, err := io.Copy(buffer, z.reader) 37 | if err != nil { 38 | return err 39 | } 40 | 41 | zr, err := zip.NewReader(buffer, size) 42 | if err != nil { 43 | return fmt.Errorf("failed to create zip reader: %w", err) 44 | } 45 | 46 | var symlinks []link 47 | for _, f := range zr.File { 48 | // Clean the name in the header to prevent './filename' being stripped to 49 | // 'filename' also to skip if the destination it the destination directory 50 | // itself i.e. './' 51 | var name string 52 | if name = filepath.Clean(f.Name); name == "." { 53 | continue 54 | } 55 | 56 | err = checkExtractPath(name, destination) 57 | if err != nil { 58 | return err 59 | } 60 | 61 | fileNames := strings.Split(name, "/") 62 | 63 | // Checks to see if file should be written when stripping components 64 | if len(fileNames) <= z.components { 65 | continue 66 | } 67 | 68 | // Constructs the path that conforms to the stripped components. 69 | path := filepath.Join(append([]string{destination}, fileNames[z.components:]...)...) 70 | 71 | switch { 72 | case f.FileInfo().IsDir(): 73 | err = os.MkdirAll(path, os.ModePerm) 74 | if err != nil { 75 | return fmt.Errorf("failed to unzip directory: %w", err) 76 | } 77 | case f.FileInfo().Mode()&os.ModeSymlink != 0: 78 | fd, err := f.Open() 79 | if err != nil { 80 | return err 81 | } 82 | 83 | linkname, err := io.ReadAll(fd) 84 | if err != nil { 85 | return err 86 | } 87 | 88 | // Collect all of the headers for symlinks so that they can be verified 89 | // after all other files are written 90 | symlinks = append(symlinks, link{ 91 | name: string(linkname), 92 | path: path, 93 | }) 94 | 95 | default: 96 | err = os.MkdirAll(filepath.Dir(path), os.ModePerm) 97 | if err != nil { 98 | return fmt.Errorf("failed to unzip directory that was part of file path: %w", err) 99 | } 100 | 101 | dst, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, f.Mode()) 102 | if err != nil { 103 | return fmt.Errorf("failed to unzip file: %w", err) 104 | } 105 | 106 | src, err := f.Open() 107 | if err != nil { 108 | return err 109 | } 110 | 111 | _, err = io.Copy(dst, src) 112 | if err != nil { 113 | return err 114 | } 115 | 116 | if err := dst.Close(); err != nil { 117 | return err 118 | } 119 | 120 | if err := src.Close(); err != nil { 121 | return err 122 | } 123 | } 124 | } 125 | 126 | symlinks, err = sortLinks(symlinks) 127 | if err != nil { 128 | return err 129 | } 130 | 131 | for _, link := range symlinks { 132 | // Check to see if the file that will be linked to is valid for symlinking 133 | _, err := filepath.EvalSymlinks(linknameFullPath(link.path, link.name)) 134 | if err != nil { 135 | return fmt.Errorf("failed to evaluate symlink %s: %w", link.path, err) 136 | } 137 | 138 | err = os.Symlink(link.name, link.path) 139 | if err != nil { 140 | return fmt.Errorf("failed to unzip symlink: %s", err) 141 | } 142 | } 143 | 144 | return nil 145 | } 146 | 147 | // StripComponents removes the first n levels from the final decompression 148 | // destination. 149 | func (z ZipArchive) StripComponents(components int) ZipArchive { 150 | z.components = components 151 | return z 152 | } 153 | -------------------------------------------------------------------------------- /vacation/zipslip.go: -------------------------------------------------------------------------------- 1 | package vacation 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "path/filepath" 7 | "strings" 8 | ) 9 | 10 | // This function checks to see that the given path is within the destination 11 | // directory 12 | func checkExtractPath(tarFilePath string, destination string) error { 13 | osPath := filepath.FromSlash(tarFilePath) 14 | destpath := filepath.Join(destination, osPath) 15 | if !strings.HasPrefix(destpath, filepath.Clean(destination)+string(os.PathSeparator)) { 16 | return fmt.Errorf("illegal file path %q: the file path does not occur within the destination directory", tarFilePath) 17 | } 18 | return nil 19 | } 20 | 21 | // Generates the full path for a symlink from the linkname and the symlink path 22 | func linknameFullPath(path, linkname string) string { 23 | return filepath.Clean(filepath.Join(filepath.Dir(path), linkname)) 24 | } 25 | --------------------------------------------------------------------------------