diff --git a/.editorconfig b/.editorconfig index 72b7797..11032fe 100644 --- a/.editorconfig +++ b/.editorconfig @@ -1,22 +1,31 @@ -# Check out EditorConfig at: https://editorconfig.org +# Configuration file for EditorConfig (https://editorconfig.org) root=true [*] charset=utf-8 insert_final_newline=true -max_line_length=100 +max_line_length=80 trim_trailing_whitespace=true +[Containerfile*] +indent_style=tab + +[LICENSE] +indent_size=unset +indent_style=space + [*.go] -indent_size=2 indent_style=tab [*.md] indent_size=unset indent_style=space -[LICENSE] +[*.txtar] indent_size=unset +indent_style=unset + +[*.yml] +indent_size=2 indent_style=space -max_line_length=80 diff --git a/.gitattributes b/.gitattributes index bb4b492..5614221 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,3 +1,5 @@ +# Configuration file for git (https://git-scm.com/docs/gitattributes) + * text=auto # don't diff machine generated files diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..180512e --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,21 @@ +# Configuration file for Dependabot (https://github.com/dependabot) + +version: 2 +updates: +- package-ecosystem: github-actions + directory: / + open-pull-requests-limit: 1 + schedule: + interval: daily + time: "03:00" + labels: + - ci/cd + - dependencies +- package-ecosystem: gomod + directory: / + open-pull-requests-limit: 1 + schedule: + interval: daily + time: "03:00" + labels: + - dependencies diff --git a/.github/workflows/audit.yml b/.github/workflows/audit.yml new file mode 100644 index 0000000..0d05808 --- /dev/null +++ b/.github/workflows/audit.yml @@ -0,0 +1,30 @@ +name: Audit +on: + pull_request: + paths: + - '**/*.go' + - .github/workflows/audit.yml + - go.mod + - go.sum + push: + branches: + - main + schedule: + - cron: 0 2 * * * + workflow_dispatch: ~ + +permissions: read-all + +jobs: + vulnerabilities: + name: Vulnerabilities + runs-on: ubuntu-22.04 + steps: + - name: Checkout repository + uses: actions/checkout@v4.1.1 + - name: Install Go + uses: actions/setup-go@v5.0.0 + with: + go-version-file: go.mod + - name: Audit + run: go run tasks.go audit diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml new file mode 100644 index 0000000..117bd2f --- /dev/null +++ b/.github/workflows/check.yml @@ -0,0 +1,96 @@ +name: Check +on: + pull_request: ~ + push: + branches: + - main + +permissions: read-all + +jobs: + build: + name: Build + runs-on: ubuntu-22.04 + steps: + - name: Checkout repository + uses: actions/checkout@v4.1.1 + - name: Install Go + uses: actions/setup-go@v5.0.0 + with: + go-version-file: go.mod + - name: Build binary + run: go run tasks.go build + dogfeed: + name: Dogfeed + runs-on: ubuntu-22.04 + needs: + - test + steps: + - name: Checkout repository + uses: actions/checkout@v4.1.1 + - name: Install Go + uses: actions/setup-go@v5.0.0 + with: + go-version-file: go.mod + - name: Uninitialize ghasum + run: rm -f .github/workflows/gha.sum + - name: Run on this repository + run: | + go run ./cmd/ghasum init + go run ./cmd/ghasum verify + format: + name: Format + runs-on: ubuntu-22.04 + needs: + - build + steps: + - name: Checkout repository + uses: actions/checkout@v4.1.1 + - name: Install Go + uses: actions/setup-go@v5.0.0 + with: + go-version-file: go.mod + - name: Check source code formatting + run: go run tasks.go format-check + reproducible: + name: Reproducible build + runs-on: ubuntu-22.04 + needs: + - build + steps: + - name: Checkout repository + uses: actions/checkout@v4.1.1 + - name: Install Go + uses: actions/setup-go@v5.0.0 + with: + go-version-file: go.mod + - name: Check reproducibility + run: go run tasks.go reproducible + test: + name: Test + runs-on: ubuntu-22.04 + needs: + - build + steps: + - name: Checkout repository + uses: actions/checkout@v4.1.1 + - name: Install Go + uses: actions/setup-go@v5.0.0 + with: + go-version-file: go.mod + - name: Run tests + run: go run tasks.go coverage + vet: + name: Vet + runs-on: ubuntu-22.04 + needs: + - build + steps: + - name: Checkout repository + uses: actions/checkout@v4.1.1 + - name: Install Go + uses: actions/setup-go@v5.0.0 + with: + go-version-file: go.mod + - name: Vet source code + run: go run tasks.go vet diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml new file mode 100644 index 0000000..6996b75 --- /dev/null +++ b/.github/workflows/codeql.yml @@ -0,0 +1,28 @@ +name: CodeQL +on: + pull_request: ~ + push: + branches: + - main + +permissions: read-all + +jobs: + go: + name: Go + runs-on: ubuntu-22.04 + permissions: + security-events: write # To upload CodeQL results + steps: + - name: Checkout repository + uses: actions/checkout@v4.1.1 + - name: Install Go + uses: actions/setup-go@v5.0.0 + with: + go-version-file: go.mod + - name: Initialize CodeQL + uses: github/codeql-action/init@v3.24.3 + with: + languages: go + - name: Perform CodeQL analysis + uses: github/codeql-action/analyze@v3.24.3 diff --git a/.github/workflows/gha.sum b/.github/workflows/gha.sum new file mode 100755 index 0000000..0b7bdde --- /dev/null +++ b/.github/workflows/gha.sum @@ -0,0 +1,6 @@ +version 1 + +actions/checkout@v4.1.1 Xl8z/l21IIpcBDsjpnq7jsBPk/RY26RwvDVL8FrajmE= +actions/setup-go@v5.0.0 lSvPPozeojJimtMLZ7cX1J/h8r1i30yGoTYQbst/jA4= +github/codeql-action@v3.24.3 GLS8dPEK5utIAgML5u2KUEdc7VhRnelkSnA8wJmaYHk= +ncipollo/release-action@v1.14.0 +JAIlT/RB99JgfxlDrAcAdBnaKX4y8hyFWnHc4j7tfM= diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml new file mode 100644 index 0000000..a787db7 --- /dev/null +++ b/.github/workflows/publish.yml @@ -0,0 +1,34 @@ +name: Publish +on: + push: + tags: + - v[0-9]+.[0-9]+.[0-9]+ + +permissions: read-all + +jobs: + github-release: + name: GitHub Release + runs-on: ubuntu-22.04 + permissions: + contents: write # To create a GitHub Release + steps: + - name: Checkout repository + uses: actions/checkout@v4.1.1 + - name: Install Go + uses: actions/setup-go@v5.0.0 + with: + go-version-file: go.mod + - name: Get release version + id: version + shell: bash + run: echo "value=${GITHUB_REF#refs/tags/}" >>"${GITHUB_OUTPUT}" + - name: Compile + run: go run tasks.go build-all + - name: Create GitHub release + uses: ncipollo/release-action@v1.14.0 + with: + tag: ${{ steps.version.outputs.value }} + name: Release ${{ steps.version.outputs.value }} + body: ${{ steps.version.outputs.value }} + artifacts: ./_compiled/* diff --git a/.github/workflows/semgrep.yml b/.github/workflows/semgrep.yml new file mode 100644 index 0000000..014f376 --- /dev/null +++ b/.github/workflows/semgrep.yml @@ -0,0 +1,28 @@ +name: Semgrep +on: + push: + branches: + - main + +permissions: read-all + +jobs: + semgrep: + name: Semgrep + runs-on: ubuntu-22.04 + permissions: + security-events: write # To upload SARIF results + container: + image: returntocorp/semgrep + steps: + - name: Checkout repository + uses: actions/checkout@v4.1.1 + - name: Perform Semgrep analysis + run: semgrep ci --sarif --output semgrep.sarif + env: + SEMGREP_APP_TOKEN: ${{ secrets.SEMGREP_APP_TOKEN }} + - name: Upload Semgrep report to GitHub + uses: github/codeql-action/upload-sarif@v3.24.3 + if: ${{ failure() || success() }} + with: + sarif_file: semgrep.sarif diff --git a/.gitignore b/.gitignore index 093f628..56402be 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,9 @@ -gha.sum +# Ignore file for git (https://git-scm.com/docs/gitignore) + +/_compiled/ +/cover.html +/cover.out +/ghasum* ## IDEs .idea/ diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..09ffb23 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,50 @@ + + +# Contributing Guidelines + +The maintainers of `ghasum` welcome contributions and corrections. This includes +improvements to the documentation or code base, tests, bug fixes, and +implementations of new features. We recommend you open an issue before making +any substantial changes so you can be sure your work won't be rejected. But for +small changes, such as fixing a typo, you can open a Pull Request directly. + +If you decide to make a contribution, please use the following workflow: + +- Fork the repository. +- Create a new branch from the latest `main`. +- Make your changes on the new branch. +- Commit to the new branch and push the commit(s). +- Open a Pull Request against `main`. + +## Prerequisites + +To be able to contribute you need the following tooling: + +- [git]; +- [Go] v1.21.5 or later; +- (Recommended) a code editor with [EditorConfig] support; + +Or a [OCI] compatible container engine, in which case you can run an ephemeral +development container using the command `go run tasks.go dev-env` (if you don't +have [Go] installed, manually run the commands from the `TaskDevEnv` function in +the `tasks.go` file). + +## Tasks + +This project uses a custom Go-based task runner to run common tasks. To get +started run: + +```shell +go run tasks.go +``` + +We recommend configuring the following command alias: + +```shell +alias gask='go run tasks.go' +``` + +[editorconfig]: https://editorconfig.org/ +[git]: https://git-scm.com/ +[go]: https://go.dev/ +[oci]: https://opencontainers.org/ diff --git a/Containerfile.dev b/Containerfile.dev new file mode 100644 index 0000000..0703f6e --- /dev/null +++ b/Containerfile.dev @@ -0,0 +1,31 @@ +# MIT No Attribution +# +# Copyright (c) 2024 Eric Cornelissen +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. + +FROM docker.io/golang:1.22.0-alpine3.19 + +RUN apk add --no-cache \ + bash git perl-utils zip \ + && \ + echo "alias gask='go run tasks.go'" >~/.bashrc + +WORKDIR /ghasum +COPY go.mod go.sum ./ +RUN go mod download + +ENTRYPOINT ["/bin/bash"] diff --git a/NOTES.md b/NOTES.md deleted file mode 100644 index 3ec89fd..0000000 --- a/NOTES.md +++ /dev/null @@ -1,42 +0,0 @@ -# SHAttered git commits - -## Overview - -1. The [SHAttered website](https://shattered.io/) states it's possible to make malicious git commits with the same SHA as an existing commit. -2. However, "the attack required 'the equivalent processing power of 6,500 years of single-CPU computations {...}.'" So it's not all that feasible in practice -3. From [SHA1 on Wikipedia](https://en.wikipedia.org/wiki/SHA-1), a more recent attack requires approximately the same amount of computation "{...} at the time of publication would cost US$45K per generated collision. - -## Attack Description (in detail) - -### Steps - -1. Create valid commit and advertise it as a release (e.g. with a git tag or GitHub release). -2. Get people to use it. -3. Craft a malicious commit with the same hash. -4. People using that release now use malicious code which they can't tell from the integrity hash. - -### Discussion - -- I don't think that the older the release is (in terms of descendant commits) the harder this attack becomes. Namely, the child of a commit's only "chaining" to the parent is the parent's commit hash. So, since the commit hash didn't change there's no need to find a collision for more than one commit. The implication is that this attack can be carried out for any historical commit. -- Open questions: - - Can existing clones be used to detect this attack? What is git's behavior locally when the hash of a historic commit is unchanged but the contents did change? - - Can forks be used to detect this attack? - -### Impact - -- GitHub Actions - - Dependencies in GitHub Actions may be specified/identified/versioned by the commit SHA of the git repository that hosts the dependency (referred to as an "Action"). This is the most secure approach, alternatively it can be specified/identified/versioned using git refs (such as branches or tags) but those are inherently mutable. - - Hence, as a result of the attack GitHub Actions users that use external Actions have no guarantee the code that ran yesterday is the code that ran today. - - The only available protection is to limit what Actions that are allowed to be used in a project to Actions with a good security hygiene. -- Go mod - - Reference: -- Dependencies in Go can (default, but alternatives exist) be pulled directly from a git repository. While you may reference the version you want to use by the commit hash, the Go module system will actually compute a SHA256 hash over the source and use that in the `go.sum` file instead. It is this hash that's used for integrity purposes - - Thus, even if a malicious commit is created for an existing commit hash, the Go module system would detect this through the SHA256 hash of the source code. -- Deno - - Dependencies in Deno can (^alternatives exist) be pulled directly from a git repository. As far as I can tell it doesn't rely on the commit hash for integrity ([ref](https://docs.deno.com/runtime/manual/basics/modules/integrity_checking)) and it seems to be using SHA256 ([ref](https://github.com/denoland/deno_lockfile/blob/75e3b2800e3fd6f3d62478a6cf15fd030d91a363/src/lib.rs#L29)), though I'm unsure what it hashes. - -## Outcome - -- Deno and Go are unaffected. Only the GitHub Actions ecosystem is affected. -- Build a system for GitHub Actions that stores and compares better hashes of the Actions source code (i.e. repository). Hashes could be stored in the repository and checked as a first step of any job. - - diff --git a/README.md b/README.md index ce1eebe..c6322f6 100644 --- a/README.md +++ b/README.md @@ -1,27 +1,95 @@ + + # `ghasum` Checksums for GitHub Actions. -## Motivation +Compute and verify checksums for all GitHub Actions in a project to guarantee +that the Actions you choose to include haven't changed since. `ghasum` gives +better integrity guarantees than pinning Actions by commit hash and is also more +user friendly as well. + +## Usage + +To start using `ghasum` navigate to a project that use GitHub Actions and run: + +```shell +ghasum init +``` + +Commit the `gha.sum` file that is created so that the checksums can be verified +in the future. To verify run: + +```shell +ghasum verify +``` + +For further help with using `ghasum` run: + +```shell +ghasum help +``` + +## Recommendations + +When using ghasum it is recommend to pin all Actions to version tags. If Actions +are benign, these won't change over time. Major version tags or branch refs are +expected to change over time as changes are made to the Action, which results in +failing verification by ghasum. Commit SHAs do not have to be used because the +benefits they provide are covered by ghasum. -The dependency ecosystem for GitHub Actions is fully reliant on git. Dependencies can be specified -by a git ref (branch or tag) or commit SHA. Git refs provide no integrity guarantees. Commit SHAs do -provide integrity guarantees, but since they're based on SHA1 these guarantees are weaker then -necessary (see for example the [SHAttered] attack). +If an Action misbehaves - moving version refs after publishing - it is +recommended to use commit SHAs instead to avoid failing verification by ghasum. -Hence, this project aims to provide a way to get, record, and validate checksums for GitHub Actions -dependencies using a more modern cryptographic hashing algorithm. +```yaml +# Recommended: exact version tags +- uses: actions/checkout@v4.1.1 -## TODO +# Possible alternative: commit SHAs +- uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1 -An incomplete list of things that should be done: +# Discouraged: major version refs +- uses: actions/checkout@v4 -- [ ] Better user experience -- [ ] Improve source code and use automated testing -- [ ] At least a basic continuous integration setup -- [ ] Record some sort of version in the checksum file in order to aid in changing the format or - changing the hash algorithm in the future -- [ ] Convenient support to run as a GitHub Action to validate dependency integrity before the (rest - of the) job runs +# Discouraged: branches +- uses: actions/checkout@main +``` +## Limitations + +- Requires manual intervention when an Action is updated. +- The hashing algorithm used for checksums is not configurable. +- Checksums do not provide protections against [unpinnable actions]. + +[unpinnable actions]: https://www.paloaltonetworks.com/blog/prisma-cloud/unpinnable-actions-github-security/ + +## Background + +The dependency ecosystem for GitHub Actions is fully reliant on git. The version +of an Action to use is specified using a git ref (branch or tag) or commit SHA. +Git refs provide no integrity guarantees. And while commit SHAs do provide some +integrity guarantees, since they're based on the older SHA1 hash the guarantees +are not optimal. + +Besides being older and having better, modern algorithms available, SHA1 is +vulnerable to the [SHAttered] attack. This means it is possible for a motivated +and well-funded adversary to mount an attack on the Github Actions ecosystem. +GitHub does have [protections in place] to detect such an attack, but this is +specific to the [SHAttered] attack and, like hashing algorithms, probabilistic. + +This project is a response to that theoretical attack - providing a way to get, +record, and validate checksums for GitHub Actions dependencies using a more +secure hashing algorithm. As an added benefit, it can also be used as an +alternative to in-workflow commit SHA. + +[protections in place]: https://github.blog/2017-03-20-sha-1-collision-detection-on-github-com/ [shattered]: https://shattered.io/ + +## License + +This software is available under the Apache License 2.0 license, see [LICENSE] +for the full license text. The contents of documentation are licensed under the +[CC BY 4.0] license. + +[cc by 4.0]: https://creativecommons.org/licenses/by/4.0/ +[LICENSE]: ./LICENSE diff --git a/RELEASE.md b/RELEASE.md new file mode 100644 index 0000000..899dbce --- /dev/null +++ b/RELEASE.md @@ -0,0 +1,73 @@ + + +# Release Guidelines + +To release a new version of the `ghasum` project follow these steps (using +v1.2.3 as an example): + +1. Make sure that your local copy of the repository is up-to-date, sync: + + ```shell + git checkout main + git pull origin main + ``` + + Or clone: + + ```shell + git clone git@github.com:ericcornelissen/ghasum.git + ``` + +1. Update the version number following to the current year-month pair in the + `cmd/ghasum/version.go` file: + + ```diff + - const version = "1.2.2" + + const version = "1.2.3" + ``` + +1. Commit the changes to a new branch and push using: + + ```shell + git checkout -b 'version-bump' + git add 'cmd/ghasum/version.go' + git commit --message 'version bump' + git push origin 'version-bump' + ``` + +1. Create a Pull Request to merge the new branch into `main`. + +1. Merge the Pull Request if the changes look OK and all continuous integration + checks are passing. + +1. Immediately after the Pull Request is merged, sync the `main` branch: + + ```shell + git checkout main + git pull origin main + ``` + +1. Create a [git tag] for the new version and push it: + + ```shell + git tag v1.2.3 + git push origin v1.2.3 + ``` + + > **Note**: At this point, the continuous delivery automation may pick up and + > complete the release process. If not, or only partially, continue following + > the remaining steps. + +1. Create pre-compiled binaries - with checksums - for various targets using: + + ```shell + go run tasks.go build-all + ``` + +1. Create a [GitHub Release] for the [git tag] of the new release. The release + title should be "Release {_version_}" (e.g. "Release v1.2.3"). The release + text should be "{_version_}" (e.g. "v1.2.3"). The release artifact should be + the pre-compiled binaries, including checksums, from the previous step. + +[git tag]: https://git-scm.com/book/en/v2/Git-Basics-Tagging +[github release]: https://docs.github.com/en/repositories/releasing-projects-on-github/managing-releases-in-a-repository diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..2047610 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,56 @@ + + +# Security Policy + +The maintainers of the `ghasum` project take security issues seriously. We +appreciate your efforts to responsibly disclose your findings. Due to the +non-funded and open-source nature of the project, we take a best-efforts +approach when it comes to engaging with security reports. + +## Supported Versions + +Only the latest release of the project is supported with security updates. + +## Reporting a Vulnerability + +To report a security issue in the latest release or development head, either: + +- [Report it through GitHub][new github advisory], or +- Send an email to [security@ericcornelissen.dev] with the terms "SECURITY" and + "ghasum" in the subject line. + +Please do not open a regular issue or Pull Request in the public repository. + +To report a security issue in an older version - i.e. the latest release isn't +affected - please report it publicly. For example, as a regular issue in the +public repository. If in doubt, report the issue privately. + +[new github advisory]: https://github.com/ericcornelissen/ghasum/security/advisories/new +[security@ericcornelissen.dev]: mailto:security@ericcornelissen.dev?subject=SECURITY%20%28ghasum%29 + +### What to Include in a Report + +Try to include as many of the following items as possible in a security report: + +- An explanation of the problem +- A proof of concept exploit +- A suggested severity +- Relevant [CWE] identifiers +- The latest affected version +- The earliest affected version +- A suggested patch +- An automated regression test + +[cwe]: https://cwe.mitre.org/ + +## Advisories + +| ID | Date | Affected version(s) | Patched version(s) | +| :--------------- | :--------- | :------------------ | :----------------- | +| - | - | - | - | + +## Acknowledgments + +We would like to publicly thank the following reporters: + +- _None yet_ diff --git a/SPECIFICATION.md b/SPECIFICATION.md new file mode 100644 index 0000000..8e9150e --- /dev/null +++ b/SPECIFICATION.md @@ -0,0 +1,131 @@ + + +# Specification of `ghasum` + +The specification aims to clarify how `ghasum` operates. Any discrepancy with +the implementation or ambiguity in the specification can be reported as a bug. +There is no guarantee on whether the specification or implementation is correct. + +## Actions + +### `ghasum init` + +If the checksum file exists the process shall exit immediately with an error. + +If the checksum file does not exist the process creates it immediately and +obtains a lock on it. If this is not possible the process should exit +immediately (it means either 1. the file has been created since it was checked +and so is not owned by us, or 2. the file could not be created and so cannot be +initialized). + +If the file lock is obtained, the process will compute checksums for all actions +used in the repository (see [Computing Checksums]) using the best available +hashing algorithm. Then it stores them in a sumfile (see [Storing Checksums]) +using the latest sumfile version and releases the lock. + +If the process fails an attempt should be made to remove the created file (if +removing fails the error is ignored). + +### `ghasum update` + +If the checksum file does not exist the process shall exit immediately with an +error. + +If the checksum file exists the process shall obtain a lock on it, if this is +not possible to process shall exit immediately (it means the file may be edited +by another process leading to an inconsistent state). + +If the file lock is obtained, the process shall first read it and parse it +partially to extract the sumfile version. If this fails the process shall exit +immediately. Else it shall recompute checksums for all actions used in the +repository (see [Computing Checksums]) using the best available hashing +algorithm. It shall then store them in a sumfile (see [Storing Checksums]) using +the same sumfile version as before and releases the lock. As a consequence, this +adds missing and removes redundant checksums from the sumfile. + +This process does not verify any of the checksums currently in the sumfile. + +### `ghasum verify` + +If the checksum file does not exist the process shall exit immediately with an +error. + +If the checksum file exists the process shall read and parse it fully. If this +fails the process shall exit immediately. Else it shall recompute the checksums +(see [Computing Checksums]) for all actions in the repository using the same +hashing algorithm as was used for the stored checksums. It shall then compare +the computed checksums against the stored checksums. + +If any of the checksums does not match or is missing the process shall exit with +a non-zero exit code, for usability all values should be compared (and all +mismatches reported) before exiting. + +Redundant checksums are ignored by this process. + +## Procedures + +### Computing Checksums + +To compute checksums `ghasum` will pull the repository of an action, either at +a specific ref or checking out the ref after pulling, remove the git index (i.e. +the `.git/` directory) and compute a deterministic hash over the files in the +repository, recursing through nested directories. + +The hash is not configurable and the only available algorithm is SHA256. + +For this process a local cache may be used. The cache will contain repositories +to avoid having to fetch them again. The cache does not contain checksums, which +will always be recomputed. + +The user is able to control the usage of the cache using the `-cache ` and +`-no-cache` flags. Additionally, the `ghasum cache` command can be used to +manage the cache. + +### Storing Checksums + +To store checksums `ghasum` uses the checksum file. This file tracks the version +of this file, checksums, and additional metadata. The version of the file and +additional metadata are all stored as _headers_. The way in which checksums are +stored depends on the version of the file, see [Sumfile Versions]. + +## Sumfile Versions + +A checksum must always contain a header named _version_ which states the version +of the sumfile. Additional non-empty lines are considered headers. A header is +interpreted as ` `. The first empty line marks the end of the +headers, the following line marks the start of the body of the sumfile. A +sumfile must always end with a final newline. There is no support for comments +in a sumfile. + +At a high level a `ghasum` sumfile looks like: + +```text +version 1 + +... + + +``` + +### Version 1 + +Sumfile version 1 expects at least one header, namely `version 1`. Any other +headers in the file are ignored. All checksums are stored on a separate line, no +additional empty lines are allowed. + +```text +version 1 + + + +... + +``` + +## Definitions + +- _checksum file_ is the file `.github/workflows/gha.sum`. + +[computing checksums]: #computing-checksums +[storing checksums]: #storing-checksums +[sumfile versions]: #sumfile-versions diff --git a/cmd/ghasum/cache.go b/cmd/ghasum/cache.go new file mode 100644 index 0000000..276f25e --- /dev/null +++ b/cmd/ghasum/cache.go @@ -0,0 +1,84 @@ +// Copyright 2024 Eric Cornelissen +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package main + +import ( + "errors" + "flag" + "fmt" + "os" + + "github.com/ericcornelissen/ghasum/internal/cache" +) + +func cmdCache(argv []string) error { + var ( + flags = flag.NewFlagSet(cmdNameCache, flag.ContinueOnError) + flagCache = flags.String(flagNameCache, "", "") + ) + + flags.Usage = func() { fmt.Fprintln(os.Stderr) } + if err := flags.Parse(argv); err != nil { + return errUsage + } + + args := flags.Args() + if len(args) < 1 { + return errUsage + } else if len(args) > 1 { + return errors.New("only one command can be run at the time") + } + + c, err := cache.New(*flagCache, false) + if err != nil { + return errors.Join(errUnexpected, err) + } + + msg := "Ok" + command := args[0] + switch command { + case "clear": + err = c.Clear() + case "path": + msg = c.Path() + default: + return fmt.Errorf(`unknown command %q (see "ghasum help cache")`, command) + } + + if err != nil { + return errors.Join(errUnexpected, err) + } + + fmt.Println(msg) + return nil +} + +func helpCache() string { + return `usage: ghasum cache [flags] + +Utilities for managing the ghasum cache. This cache is where ghasum stores and +looks up repositories it needs to do its job. + +The available commands are: + + clear Remove all data from the cache. + path Show the path to the cache. + +The available flags are: + + -cache dir + The location of the cache directory. Defaults to a directory named + .ghasum/ in the user's home directory.` +} diff --git a/cmd/ghasum/cache_test.go b/cmd/ghasum/cache_test.go new file mode 100644 index 0000000..4c53589 --- /dev/null +++ b/cmd/ghasum/cache_test.go @@ -0,0 +1,31 @@ +// Copyright 2024 Eric Cornelissen +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package main + +import ( + "testing" + + "github.com/rogpeppe/go-internal/testscript" +) + +func TestCache(t *testing.T) { + t.Parallel() + + params := testscript.Params{ + Dir: "../../testdata/cache", + } + + testscript.Run(t, params) +} diff --git a/cmd/ghasum/common.go b/cmd/ghasum/common.go new file mode 100644 index 0000000..a1b476b --- /dev/null +++ b/cmd/ghasum/common.go @@ -0,0 +1,33 @@ +// Copyright 2024 Eric Cornelissen +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package main + +import ( + "errors" + "os" +) + +func getTarget(args []string) (string, error) { + if len(args) == 0 { + wd, err := os.Getwd() + if err != nil { + return "", errors.New("could not get current working directory, provide explicit target") + } + + return wd, nil + } else { + return args[0], nil + } +} diff --git a/cmd/ghasum/doc.go b/cmd/ghasum/doc.go new file mode 100644 index 0000000..80f5c03 --- /dev/null +++ b/cmd/ghasum/doc.go @@ -0,0 +1,19 @@ +// Copyright 2024 Eric Cornelissen +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// The ghasum command can be used to create, verify, and manage checksums for +// GitHub Actions. +// +// Run "ghasum help" to get started. +package main diff --git a/cmd/ghasum/help.go b/cmd/ghasum/help.go new file mode 100644 index 0000000..f4408a9 --- /dev/null +++ b/cmd/ghasum/help.go @@ -0,0 +1,66 @@ +// Copyright 2024 Eric Cornelissen +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package main + +import ( + "errors" + "flag" + "fmt" +) + +func cmdHelp(argv []string) error { + flagsHelp := flag.NewFlagSet(cmdNameHelp, flag.ContinueOnError) + if err := flagsHelp.Parse(argv); err != nil { + return errUsage + } + + args := flagsHelp.Args() + switch len(args) { + case 0: + fmt.Println(help()) + return nil + case 1: + return helpFor(args[0]) + default: + return errors.New("you can ask help for only one command at the time") + } +} + +func helpFor(command string) error { + fn, ok := helpers[command] + if !ok { + return fmt.Errorf(`unknown command %q (see "ghasum help")`, command) + } + + fmt.Println(fn()) + return nil +} + +func help() string { + return `usage: ghasum [arguments] + +Checksums manager for the GitHub Action ecosystem. Track and verify checksums +for the GitHub Actions used in a project to avoid using Actions that changed. + +The available commands are: + + cache Manage the ghasum cache. + init Initialize ghasum for a repository. + update Update the checksums for a repository. + verify Verify the checksums for a repository. + version Print the ghasum version. + +Use "ghasum help " for more information about a command.` +} diff --git a/cmd/ghasum/help_test.go b/cmd/ghasum/help_test.go new file mode 100644 index 0000000..7efeecd --- /dev/null +++ b/cmd/ghasum/help_test.go @@ -0,0 +1,31 @@ +// Copyright 2024 Eric Cornelissen +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package main + +import ( + "testing" + + "github.com/rogpeppe/go-internal/testscript" +) + +func TestHelp(t *testing.T) { + t.Parallel() + + params := testscript.Params{ + Dir: "../../testdata/help", + } + + testscript.Run(t, params) +} diff --git a/cmd/ghasum/init.go b/cmd/ghasum/init.go new file mode 100644 index 0000000..836c1be --- /dev/null +++ b/cmd/ghasum/init.go @@ -0,0 +1,83 @@ +// Copyright 2024 Eric Cornelissen +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package main + +import ( + "errors" + "flag" + "fmt" + "os" + + "github.com/ericcornelissen/ghasum/internal/cache" + "github.com/ericcornelissen/ghasum/internal/ghasum" +) + +func cmdInit(argv []string) error { + var ( + flags = flag.NewFlagSet(cmdNameInit, flag.ContinueOnError) + flagCache = flags.String(flagNameCache, "", "") + flagNoCache = flags.Bool(flagNameNoCache, false, "") + ) + + flags.Usage = func() { fmt.Fprintln(os.Stderr) } + if err := flags.Parse(argv); err != nil { + return errUsage + } + + args := flags.Args() + if len(args) > 1 { + return errUsage + } + + target, err := getTarget(args) + if err != nil { + return err + } + + c, err := cache.New(*flagCache, *flagNoCache) + if err != nil { + return errors.Join(errCache, err) + } + + cfg := ghasum.Config{ + Repo: os.DirFS(target), + Path: target, + Cache: c, + } + + if err := ghasum.Initialize(&cfg); err != nil { + return errors.Join(errUnexpected, err) + } + + fmt.Println("Ok") + return nil +} + +func helpInit() string { + return `usage: ghasum init [flags] [target] + +Initialize ghasum for the target. If no target is provided it will default to +the current working directory. If ghasum is already initialize for the target +this command will error. + +The available flags are: + + -cache dir + The location of the cache directory. This is where ghasum stores and + looks up repositories it needs. + Defaults to a directory named .ghasum in the user's home directory. + -no-cache + Disable the use of the cache. Makes the -cache flag ineffective.` +} diff --git a/cmd/ghasum/init_test.go b/cmd/ghasum/init_test.go new file mode 100644 index 0000000..23f2a81 --- /dev/null +++ b/cmd/ghasum/init_test.go @@ -0,0 +1,31 @@ +// Copyright 2024 Eric Cornelissen +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package main + +import ( + "testing" + + "github.com/rogpeppe/go-internal/testscript" +) + +func TestInit(t *testing.T) { + t.Parallel() + + params := testscript.Params{ + Dir: "../../testdata/init", + } + + testscript.Run(t, params) +} diff --git a/cmd/ghasum/main.go b/cmd/ghasum/main.go new file mode 100644 index 0000000..1b67aa8 --- /dev/null +++ b/cmd/ghasum/main.go @@ -0,0 +1,109 @@ +// Copyright 2024 Eric Cornelissen +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package main + +import ( + "errors" + "fmt" + "os" +) + +type ( + // A Command is a function that performs a ghasum command. + Command func(args []string) error + + // A Helper is a function that returns the help text for a ghasum command. + Helper func() string +) + +const ( + cmdNameCache = "cache" + cmdNameHelp = "help" + cmdNameInit = "init" + cmdNameUpdate = "update" + cmdNameVerify = "verify" + cmdNameVersion = "version" +) + +const ( + exitCodeSuccess = iota + exitCodeError + exitCodeUsage + exitCodeFailure +) + +const ( + flagNameCache = "cache" + flagNameNoCache = "no-cache" +) + +var ( + errCache = errors.New("cache error (using -cache or -no-cache may avoid this error)") + errFailure = errors.New("") + errUsage = errors.New("") + errUnexpected = errors.New("an unexpected error occurred") +) + +var commands = map[string]Command{ + cmdNameCache: cmdCache, + cmdNameHelp: cmdHelp, + cmdNameInit: cmdInit, + cmdNameUpdate: cmdUpdate, + cmdNameVerify: cmdVerify, + cmdNameVersion: cmdVersion, +} + +var helpers = map[string]Helper{ + cmdNameCache: helpCache, + cmdNameHelp: help, + cmdNameInit: helpInit, + cmdNameUpdate: helpUpdate, + cmdNameVerify: helpVerify, + cmdNameVersion: helpVersion, +} + +func main() { + os.Exit(run()) +} + +func run() int { + if len(os.Args) < 2 { + fmt.Println(help()) + return exitCodeSuccess + } + + command := os.Args[1] + fn, ok := commands[command] + if !ok { + fmt.Println(help()) + return exitCodeUsage + } + + err := fn(os.Args[2:]) + switch { + case err == nil: + return exitCodeSuccess + case errors.Is(err, errUsage): + helpFn := helpers[command] + fmt.Println(helpFn()) + return exitCodeUsage + case errors.Is(err, errFailure): + fmt.Println(err) + return exitCodeFailure + default: + fmt.Fprintln(os.Stderr, err) + return exitCodeError + } +} diff --git a/cmd/ghasum/main_test.go b/cmd/ghasum/main_test.go new file mode 100644 index 0000000..33054f8 --- /dev/null +++ b/cmd/ghasum/main_test.go @@ -0,0 +1,115 @@ +// Copyright 2024 Eric Cornelissen +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package main + +import ( + "os" + "strings" + "testing" + + "github.com/rogpeppe/go-internal/testscript" +) + +func TestMain(m *testing.M) { + commands := map[string]func() int{ + "ghasum": run, + } + + os.Exit(testscript.RunMain(m, commands)) +} + +func TestCli(t *testing.T) { + t.Parallel() + + params := testscript.Params{ + Dir: "../../testdata", + } + + testscript.Run(t, params) +} + +func TestExitCodes(t *testing.T) { + t.Parallel() + + if got := exitCodeSuccess; got != 0 { + t.Fatalf("Exit code for success must be 0 (got %d)", got) + } + + exitCodes := []int{ + exitCodeSuccess, + exitCodeError, + exitCodeUsage, + exitCodeFailure, + } + + for i, a := range exitCodes { + for j, b := range exitCodes { + if i != j && a == b { + t.Fatalf("Exit codes must be unique (%d and %d are identical)", i, j) + } + } + } +} + +func TestCommands(t *testing.T) { + t.Parallel() + + for name, fn := range commands { + name, fn := name, fn + t.Run(name, func(t *testing.T) { + t.Parallel() + + if fn == nil { + t.Fatal("Command should not be nil") + } + }) + } +} + +func TestHelpers(t *testing.T) { + t.Parallel() + + for name, fn := range helpers { + name, fn := name, fn + t.Run(name, func(t *testing.T) { + t.Parallel() + + if fn == nil { + t.Fatal("Helper should not be nil") + } + + got := fn() + if want := "ghasum " + name; !strings.Contains(got, want) { + t.Errorf("Help is missing substring %q", want) + } + + if strings.TrimSpace(got) != got { + t.Errorf("Help should not have leading nor trailing whitespace") + } + + if strings.Contains(got, "") { + if want := "The available commands are:"; !strings.Contains(got, want) { + t.Errorf("Command accepts a command but does not list them (missing %q)", want) + } + } + + if strings.Contains(got, "[flags]") { + if want := "The available flags are:"; !strings.Contains(got, want) { + t.Errorf("Command accepts flags but does not describe them (missing %q)", want) + } + } + }) + } +} diff --git a/cmd/ghasum/update.go b/cmd/ghasum/update.go new file mode 100644 index 0000000..33c1b1b --- /dev/null +++ b/cmd/ghasum/update.go @@ -0,0 +1,84 @@ +// Copyright 2024 Eric Cornelissen +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package main + +import ( + "errors" + "flag" + "fmt" + "os" + + "github.com/ericcornelissen/ghasum/internal/cache" + "github.com/ericcornelissen/ghasum/internal/ghasum" +) + +func cmdUpdate(argv []string) error { + var ( + flags = flag.NewFlagSet(cmdNameUpdate, flag.ContinueOnError) + flagCache = flags.String(flagNameCache, "", "") + flagNoCache = flags.Bool(flagNameNoCache, false, "") + ) + + flags.Usage = func() { fmt.Fprintln(os.Stderr) } + if err := flags.Parse(argv); err != nil { + return errUsage + } + + args := flags.Args() + if len(args) > 1 { + return errUsage + } + + target, err := getTarget(args) + if err != nil { + return err + } + + c, err := cache.New(*flagCache, *flagNoCache) + if err != nil { + return errors.Join(errCache, err) + } + + cfg := ghasum.Config{ + Repo: os.DirFS(target), + Path: target, + Cache: c, + } + + if err := ghasum.Update(&cfg); err != nil { + return errors.Join(errUnexpected, err) + } + + fmt.Println("Ok") + return nil +} + +func helpUpdate() string { + return `usage: ghasum update [flags] [target] + +Update the checksums in the gha.sum file for the target's current Actions. If no +target is provided it will default to the current working directory. + +If ghasum is not yet initialized this command errors (see "ghasum help init"). + +The available flags are: + + -cache dir + The location of the cache directory. This is where ghasum stores and + looks up repositories it needs. + Defaults to a directory named .ghasum in the user's home directory. + -no-cache + Disable the use of the cache. Makes the -cache flag ineffective.` +} diff --git a/cmd/ghasum/update_test.go b/cmd/ghasum/update_test.go new file mode 100644 index 0000000..3f18eec --- /dev/null +++ b/cmd/ghasum/update_test.go @@ -0,0 +1,31 @@ +// Copyright 2024 Eric Cornelissen +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package main + +import ( + "testing" + + "github.com/rogpeppe/go-internal/testscript" +) + +func TestUpdate(t *testing.T) { + t.Parallel() + + params := testscript.Params{ + Dir: "../../testdata/update", + } + + testscript.Run(t, params) +} diff --git a/cmd/ghasum/verify.go b/cmd/ghasum/verify.go new file mode 100644 index 0000000..9d8d688 --- /dev/null +++ b/cmd/ghasum/verify.go @@ -0,0 +1,97 @@ +// Copyright 2024 Eric Cornelissen +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package main + +import ( + "errors" + "flag" + "fmt" + "os" + "strings" + + "github.com/ericcornelissen/ghasum/internal/cache" + "github.com/ericcornelissen/ghasum/internal/ghasum" +) + +func cmdVerify(argv []string) error { + var ( + flags = flag.NewFlagSet(cmdNameVerify, flag.ContinueOnError) + flagCache = flags.String(flagNameCache, "", "") + flagNoCache = flags.Bool(flagNameNoCache, false, "") + ) + + flags.Usage = func() { fmt.Fprintln(os.Stderr) } + if err := flags.Parse(argv); err != nil { + return errUsage + } + + args := flags.Args() + if len(args) > 1 { + return errUsage + } + + target, err := getTarget(args) + if err != nil { + return err + } + + c, err := cache.New(*flagCache, *flagNoCache) + if err != nil { + return errors.Join(errCache, err) + } + + cfg := ghasum.Config{ + Repo: os.DirFS(target), + Path: target, + Cache: c, + } + + problems, err := ghasum.Verify(&cfg) + if err != nil { + return errors.Join(errUnexpected, err) + } + + if cnt := len(problems); cnt > 0 { + var sb strings.Builder + sb.WriteString(fmt.Sprintf("%d problems(s) occurred during validation:\n", cnt)) + for _, problem := range problems { + sb.WriteString(fmt.Sprintf(" %s\n", problem)) + } + + return errors.Join(errFailure, errors.New(sb.String())) + } + + fmt.Println("Ok") + return nil +} + +func helpVerify() string { + return `usage: ghasum verify [flags] [target] + +Verify the Actions in the target against the stored checksums. If no target is +provided it will default to the current working directory. If the checksums do +not match this command will error with a non-zero exit code. + +If ghasum is not yet initialized this command errors (see "ghasum help init"). + +The available flags are: + + -cache dir + The location of the cache directory. This is where ghasum stores and + looks up repositories it needs. + Defaults to a directory named .ghasum in the user's home directory. + -no-cache + Disable the use of the cache. Makes the -cache flag ineffective.` +} diff --git a/cmd/ghasum/verify_test.go b/cmd/ghasum/verify_test.go new file mode 100644 index 0000000..e15b977 --- /dev/null +++ b/cmd/ghasum/verify_test.go @@ -0,0 +1,31 @@ +// Copyright 2024 Eric Cornelissen +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package main + +import ( + "testing" + + "github.com/rogpeppe/go-internal/testscript" +) + +func TestVerify(t *testing.T) { + t.Parallel() + + params := testscript.Params{ + Dir: "../../testdata/verify", + } + + testscript.Run(t, params) +} diff --git a/cmd/ghasum/version.go b/cmd/ghasum/version.go new file mode 100644 index 0000000..64cf007 --- /dev/null +++ b/cmd/ghasum/version.go @@ -0,0 +1,48 @@ +// Copyright 2024 Eric Cornelissen +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package main + +import ( + "flag" + "fmt" + "os" +) + +const version = "0.1.0" + +func cmdVersion(argv []string) error { + var ( + flags = flag.NewFlagSet(cmdNameVersion, flag.ContinueOnError) + ) + + flags.Usage = func() { fmt.Fprintln(os.Stderr) } + if err := flags.Parse(argv); err != nil { + return errUsage + } + + args := flags.Args() + if len(args) != 0 { + return errUsage + } + + fmt.Printf("v%s\n", version) + return nil +} + +func helpVersion() string { + return `usage: ghasum version + +Prints the version of ghasum.` +} diff --git a/cmd/ghasum/version_test.go b/cmd/ghasum/version_test.go new file mode 100644 index 0000000..5b69d95 --- /dev/null +++ b/cmd/ghasum/version_test.go @@ -0,0 +1,31 @@ +// Copyright 2024 Eric Cornelissen +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package main + +import ( + "testing" + + "github.com/rogpeppe/go-internal/testscript" +) + +func TestVersion(t *testing.T) { + t.Parallel() + + params := testscript.Params{ + Dir: "../../testdata/version", + } + + testscript.Run(t, params) +} diff --git a/go.mod b/go.mod index cdf9e50..fdae97c 100644 --- a/go.mod +++ b/go.mod @@ -1,31 +1,109 @@ module github.com/ericcornelissen/ghasum -go 1.21.0 +go 1.22.0 -require github.com/go-git/go-git/v5 v5.9.0 +require ( + 4d63.com/gochecknoinits v0.0.0-20210416043744-25bb07f6e4e3 + github.com/alexkohler/dogsled v0.0.0-20240130174141-bb1f9c4c0c98 + github.com/alexkohler/nakedret/v2 v2.0.2 + github.com/alexkohler/prealloc v1.0.0 + github.com/alexkohler/unimport v0.0.0-20171106223308-e6f2b2e2d406 + github.com/butuzov/ireturn v0.3.0 + github.com/catenacyber/perfsprint v0.7.0 + github.com/dkorunic/betteralign v0.4.0 + github.com/go-critic/go-critic v0.11.1 + github.com/go-git/go-git/v5 v5.11.0 + github.com/gordonklaus/ineffassign v0.1.0 + github.com/jgautheron/goconst v1.7.0 + github.com/kisielk/errcheck v1.7.0 + github.com/kunwardeep/paralleltest v1.0.10 + github.com/liamg/memoryfs v1.6.0 + github.com/mdempsky/unconvert v0.0.0-20230907125504-415706980c06 + github.com/nishanths/exhaustive v0.12.0 + github.com/polyfloyd/go-errorlint v1.4.8 + github.com/remyoudompheng/go-misc v0.0.0-20190427085024-2d6ac652a50e + github.com/rogpeppe/go-internal v1.12.0 + github.com/tetafro/godot v1.4.16 + github.com/tomarrell/wrapcheck/v2 v2.8.1 + github.com/ultraware/whitespace v0.1.0 + gitlab.com/bosi/decorder v0.4.1 + go.uber.org/nilaway v0.0.0-20240216175439-fb8b98c43554 + golang.org/x/tools v0.18.0 + golang.org/x/vuln v1.0.4 + gopkg.in/yaml.v2 v2.4.0 + honnef.co/go/tools v0.4.6 + mvdan.cc/unparam v0.0.0-20240104100049-c549a3470d14 +) require ( dario.cat/mergo v1.0.0 // indirect + github.com/BurntSushi/toml v1.3.2 // indirect + github.com/KimMachineGun/automemlimit v0.5.0 // indirect github.com/Microsoft/go-winio v0.6.1 // indirect - github.com/ProtonMail/go-crypto v0.0.0-20230828082145-3c4c8a2d2371 // indirect - github.com/acomagu/bufpipe v1.0.4 // indirect - github.com/cloudflare/circl v1.3.3 // indirect + github.com/ProtonMail/go-crypto v1.0.0 // indirect + github.com/cilium/ebpf v0.13.0 // indirect + github.com/cloudflare/circl v1.3.7 // indirect + github.com/containerd/cgroups/v3 v3.0.3 // indirect + github.com/coreos/go-systemd/v22 v22.5.0 // indirect + github.com/cristalhq/acmd v0.11.2 // indirect github.com/cyphar/filepath-securejoin v0.2.4 // indirect + github.com/docker/go-units v0.5.0 // indirect github.com/emirpasic/gods v1.18.1 // indirect + github.com/fsnotify/fsnotify v1.7.0 // indirect github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect github.com/go-git/go-billy/v5 v5.5.0 // indirect + github.com/go-toolsmith/astcast v1.1.0 // indirect + github.com/go-toolsmith/astcopy v1.1.0 // indirect + github.com/go-toolsmith/astequal v1.2.0 // indirect + github.com/go-toolsmith/astfmt v1.1.0 // indirect + github.com/go-toolsmith/astp v1.1.0 // indirect + github.com/go-toolsmith/pkgload v1.2.2 // indirect + github.com/go-toolsmith/strparse v1.1.0 // indirect + github.com/go-toolsmith/typep v1.1.0 // indirect + github.com/gobwas/glob v0.2.3 // indirect + github.com/godbus/dbus/v5 v5.1.0 // indirect github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect + github.com/google/go-cmp v0.6.0 // indirect + github.com/google/renameio/v2 v2.0.0 // indirect + github.com/hashicorp/hcl v1.0.0 // indirect github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect github.com/kevinburke/ssh_config v1.2.0 // indirect + github.com/klauspost/compress v1.17.6 // indirect + github.com/magiconair/properties v1.8.7 // indirect + github.com/mitchellh/mapstructure v1.5.0 // indirect + github.com/opencontainers/runtime-spec v1.2.0 // indirect + github.com/pbnjay/memory v0.0.0-20210728143218-7b4eea64cf58 // indirect + github.com/pelletier/go-toml/v2 v2.1.1 // indirect github.com/pjbgf/sha1cd v0.3.0 // indirect - github.com/sergi/go-diff v1.1.0 // indirect - github.com/skeema/knownhosts v1.2.0 // indirect + github.com/quasilyte/go-ruleguard v0.4.0 // indirect + github.com/quasilyte/gogrep v0.5.0 // indirect + github.com/quasilyte/regex/syntax v0.0.0-20210819130434-b3f0c404a727 // indirect + github.com/quasilyte/stdinfo v0.0.0-20220114132959-f7386bf02567 // indirect + github.com/sagikazarmark/locafero v0.4.0 // indirect + github.com/sagikazarmark/slog-shim v0.1.0 // indirect + github.com/sergi/go-diff v1.3.1 // indirect + github.com/sirkon/dst v0.26.4 // indirect + github.com/sirupsen/logrus v1.9.3 // indirect + github.com/skeema/knownhosts v1.2.1 // indirect + github.com/sourcegraph/conc v0.3.0 // indirect + github.com/spf13/afero v1.11.0 // indirect + github.com/spf13/cast v1.6.0 // indirect + github.com/spf13/pflag v1.0.5 // indirect + github.com/spf13/viper v1.18.2 // indirect + github.com/subosito/gotenv v1.6.0 // indirect github.com/xanzy/ssh-agent v0.3.3 // indirect - golang.org/x/crypto v0.13.0 // indirect - golang.org/x/mod v0.12.0 - golang.org/x/net v0.15.0 // indirect - golang.org/x/sys v0.12.0 // indirect - golang.org/x/tools v0.13.0 // indirect + go.uber.org/automaxprocs v1.5.3 // indirect + go.uber.org/multierr v1.11.0 // indirect + golang.org/x/crypto v0.19.0 // indirect + golang.org/x/exp v0.0.0-20240213143201-ec583247a57a // indirect + golang.org/x/exp/typeparams v0.0.0-20240213143201-ec583247a57a // indirect + golang.org/x/mod v0.15.0 // indirect + golang.org/x/net v0.21.0 // indirect + golang.org/x/sync v0.6.0 // indirect + golang.org/x/sys v0.17.0 // indirect + golang.org/x/text v0.14.0 // indirect + google.golang.org/protobuf v1.32.0 // indirect + gopkg.in/ini.v1 v1.67.0 // indirect gopkg.in/warnings.v0 v0.1.2 // indirect - gopkg.in/yaml.v3 v3.0.1 + gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index dd0945f..ff14dc6 100644 --- a/go.sum +++ b/go.sum @@ -1,46 +1,126 @@ +4d63.com/gochecknoinits v0.0.0-20210416043744-25bb07f6e4e3 h1:dA+MoC4Kt+VGJ4ZAlMLweBi1JAcYvlXwKlpJkjmC64k= +4d63.com/gochecknoinits v0.0.0-20210416043744-25bb07f6e4e3/go.mod h1:4o1i5aXtIF5tJFt3UD1knCVmWOXg7fLYdHVu6jeNcnM= dario.cat/mergo v1.0.0 h1:AGCNq9Evsj31mOgNPcLyXc+4PNABt905YmuqPYYpBWk= dario.cat/mergo v1.0.0/go.mod h1:uNxQE+84aUszobStD9th8a29P2fMDhsBdgRYvZOxGmk= +github.com/BurntSushi/toml v1.3.2 h1:o7IhLm0Msx3BaB+n3Ag7L8EVlByGnpq14C4YWiu/gL8= +github.com/BurntSushi/toml v1.3.2/go.mod h1:CxXYINrC8qIiEnFrOxCa7Jy5BFHlXnUU2pbicEuybxQ= +github.com/KimMachineGun/automemlimit v0.5.0 h1:BeOe+BbJc8L5chL3OwzVYjVzyvPALdd5wxVVOWuUZmQ= +github.com/KimMachineGun/automemlimit v0.5.0/go.mod h1:di3GCKiu9Y+1fs92erCbUvKzPkNyViN3mA0vti/ykEQ= github.com/Microsoft/go-winio v0.5.2/go.mod h1:WpS1mjBmmwHBEWmogvA2mj8546UReBk4v8QkMxJ6pZY= github.com/Microsoft/go-winio v0.6.1 h1:9/kr64B9VUZrLm5YYwbGtUJnMgqWVOdUAXu6Migciow= github.com/Microsoft/go-winio v0.6.1/go.mod h1:LRdKpFKfdobln8UmuiYcKPot9D2v6svN5+sAH+4kjUM= -github.com/ProtonMail/go-crypto v0.0.0-20230828082145-3c4c8a2d2371 h1:kkhsdkhsCvIsutKu5zLMgWtgh9YxGCNAw8Ad8hjwfYg= -github.com/ProtonMail/go-crypto v0.0.0-20230828082145-3c4c8a2d2371/go.mod h1:EjAoLdwvbIOoOQr3ihjnSoLZRtE8azugULFRteWMNc0= -github.com/acomagu/bufpipe v1.0.4 h1:e3H4WUzM3npvo5uv95QuJM3cQspFNtFBzvJ2oNjKIDQ= -github.com/acomagu/bufpipe v1.0.4/go.mod h1:mxdxdup/WdsKVreO5GpW4+M/1CE2sMG4jeGJ2sYmHc4= +github.com/ProtonMail/go-crypto v1.0.0 h1:LRuvITjQWX+WIfr930YHG2HNfjR1uOfyf5vE0kC2U78= +github.com/ProtonMail/go-crypto v1.0.0/go.mod h1:EjAoLdwvbIOoOQr3ihjnSoLZRtE8azugULFRteWMNc0= +github.com/alexkohler/dogsled v0.0.0-20240130174141-bb1f9c4c0c98 h1:yng7lub86U6RzE1SNbPBLs1vovL9gxxNz/jvqr2WMlM= +github.com/alexkohler/dogsled v0.0.0-20240130174141-bb1f9c4c0c98/go.mod h1:JKZcwNsCuXMHGReMjwEWL8XLJjcgLm7dtLcBZMEZlDo= +github.com/alexkohler/nakedret/v2 v2.0.2 h1:qnXuZNvv3/AxkAb22q/sEsEpcA99YxLFACDtEw9TPxE= +github.com/alexkohler/nakedret/v2 v2.0.2/go.mod h1:2b8Gkk0GsOrqQv/gPWjNLDSKwG8I5moSXG1K4VIBcTQ= +github.com/alexkohler/prealloc v1.0.0 h1:Hbq0/3fJPQhNkN0dR95AVrr6R7tou91y0uHG5pOcUuw= +github.com/alexkohler/prealloc v1.0.0/go.mod h1:VetnK3dIgFBBKmg0YnD9F9x6Icjd+9cvfHR56wJVlKE= +github.com/alexkohler/unimport v0.0.0-20171106223308-e6f2b2e2d406 h1:5qYQDJAjMgBsrql8MGR7d+XNx/r4zH0PWaTq04k9TIA= +github.com/alexkohler/unimport v0.0.0-20171106223308-e6f2b2e2d406/go.mod h1:9v7wjGisFAq6j5hd8fuwm9ZHfiYtlkJSM+3EdDTrVfs= github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFIImctFaOjnTIavg87rW78vTPkQqLI8= github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be/go.mod h1:ySMOLuWl6zY27l47sB3qLNK6tF2fkHG55UZxx8oIVo4= github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPdPJAN/hZIm0C4OItdklCFmMRWYpio= github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs= +github.com/butuzov/ireturn v0.3.0 h1:hTjMqWw3y5JC3kpnC5vXmFJAWI/m31jaCYQqzkS6PL0= +github.com/butuzov/ireturn v0.3.0/go.mod h1:A09nIiwiqzN/IoVo9ogpa0Hzi9fex1kd9PSD6edP5ZA= github.com/bwesterb/go-ristretto v1.2.3/go.mod h1:fUIoIZaG73pV5biE2Blr2xEzDoMj7NFEuV9ekS419A0= -github.com/cloudflare/circl v1.3.3 h1:fE/Qz0QdIGqeWfnwq0RE0R7MI51s0M2E4Ga9kq5AEMs= +github.com/catenacyber/perfsprint v0.7.0 h1:rKQKns5tJLHtB52z/1KZ4V2NlYnyJa7MAthhoM3I3Zk= +github.com/catenacyber/perfsprint v0.7.0/go.mod h1:/wclWYompEyjUD2FuIIDVKNkqz7IgBIWXIH3V0Zol50= +github.com/cilium/ebpf v0.13.0 h1:K+41peBnbROzY6nHc9Kq79B4lnJpiF/BMpBuoTGAWSY= +github.com/cilium/ebpf v0.13.0/go.mod h1:DHp1WyrLeiBh19Cf/tfiSMhqheEiK8fXFZ4No0P1Hso= github.com/cloudflare/circl v1.3.3/go.mod h1:5XYMA4rFBvNIrhs50XuiBJ15vF2pZn4nnUKZrLbUZFA= +github.com/cloudflare/circl v1.3.7 h1:qlCDlTPz2n9fu58M0Nh1J/JzcFpfgkFHHX3O35r5vcU= +github.com/cloudflare/circl v1.3.7/go.mod h1:sRTcRWXGLrKw6yIGJ+l7amYJFfAXbZG0kBSc8r4zxgA= +github.com/containerd/cgroups/v3 v3.0.3 h1:S5ByHZ/h9PMe5IOQoN7E+nMc2UcLEM/V48DGDJ9kip0= +github.com/containerd/cgroups/v3 v3.0.3/go.mod h1:8HBe7V3aWGLFPd/k03swSIsGjZhHI2WzJmticMgVuz0= +github.com/coreos/go-systemd/v22 v22.5.0 h1:RrqgGjYQKalulkV8NGVIfkXQf6YYmOyiJKk8iXXhfZs= +github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= +github.com/cristalhq/acmd v0.11.2 h1:ITIWtBRiYbmzk+i8xQgH2RzfCVMII+dOd0CtGWVIhaU= +github.com/cristalhq/acmd v0.11.2/go.mod h1:LG5oa43pE/BbxtfMoImHCQN++0Su7dzipdgBjMCBVDQ= github.com/cyphar/filepath-securejoin v0.2.4 h1:Ugdm7cg7i6ZK6x3xDF1oEu1nfkyfH53EtKeQYTC3kyg= github.com/cyphar/filepath-securejoin v0.2.4/go.mod h1:aPGpWjXOXUn2NCNjFvBE6aRxGGx79pTxQpKOJNYHHl4= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dkorunic/betteralign v0.4.0 h1:lrsFQWUtdMMiuYrOS9w4Y5pnlqNbNUfAk75Q8fVsc/8= +github.com/dkorunic/betteralign v0.4.0/go.mod h1:V2BgnuywH4URoLfIx4HrmtzJ8vns8dOhF2cJfiYB8Tc= +github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4= +github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk= github.com/elazarl/goproxy v0.0.0-20230808193330-2592e75ae04a h1:mATvB/9r/3gvcejNsXKSkQ6lcIaNec2nyfOdlTBR2lU= github.com/elazarl/goproxy v0.0.0-20230808193330-2592e75ae04a/go.mod h1:Ro8st/ElPeALwNFlcTpWmkr6IoMFfkjXAvTHpevnDsM= github.com/emirpasic/gods v1.18.1 h1:FXtiHYKDGKCW2KzwZKx0iC0PQmdlorYgdFG9jPXJ1Bc= github.com/emirpasic/gods v1.18.1/go.mod h1:8tpGGwCnJ5H4r6BWwaV6OrWmMoPhUl5jm/FMNAnJvWQ= +github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= +github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= +github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA= +github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM= github.com/gliderlabs/ssh v0.3.5 h1:OcaySEmAQJgyYcArR+gGGTHCyE7nvhEMTlYY+Dp8CpY= github.com/gliderlabs/ssh v0.3.5/go.mod h1:8XB4KraRrX39qHhT6yxPsHedjA08I/uBVwj4xC+/+z4= +github.com/go-critic/go-critic v0.11.1 h1:/zBseUSUMytnRqxjlsYNbDDxpu3R2yH8oLXo/FOE8b8= +github.com/go-critic/go-critic v0.11.1/go.mod h1:aZVQR7+gazH6aDEQx4356SD7d8ez8MipYjXbEl5JAKA= github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 h1:+zs/tPmkDkHx3U66DAb0lQFJrpS6731Oaa12ikc+DiI= github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376/go.mod h1:an3vInlBmSxCcxctByoQdvwPiA7DTK7jaaFDBTtu0ic= github.com/go-git/go-billy/v5 v5.5.0 h1:yEY4yhzCDuMGSv83oGxiBotRzhwhNr8VZyphhiu+mTU= github.com/go-git/go-billy/v5 v5.5.0/go.mod h1:hmexnoNsr2SJU1Ju67OaNz5ASJY3+sHgFRpCtpDCKow= -github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20230305113008-0c11038e723f h1:Pz0DHeFij3XFhoBRGUDPzSJ+w2UcK5/0JvF8DRI58r8= -github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20230305113008-0c11038e723f/go.mod h1:8LHG1a3SRW71ettAD/jW13h8c6AqjVSeL11RAdgaqpo= -github.com/go-git/go-git/v5 v5.9.0 h1:cD9SFA7sHVRdJ7AYck1ZaAa/yeuBvGPxwXDL8cxrObY= -github.com/go-git/go-git/v5 v5.9.0/go.mod h1:RKIqga24sWdMGZF+1Ekv9kylsDz6LzdTSI2s/OsZWE0= +github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399 h1:eMje31YglSBqCdIqdhKBW8lokaMrL3uTkpGYlE2OOT4= +github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399/go.mod h1:1OCfN199q1Jm3HZlxleg+Dw/mwps2Wbk9frAWm+4FII= +github.com/go-git/go-git/v5 v5.11.0 h1:XIZc1p+8YzypNr34itUfSvYJcv+eYdTnTvOZ2vD3cA4= +github.com/go-git/go-git/v5 v5.11.0/go.mod h1:6GFcX2P3NM7FPBfpePbpLd21XxsgdAt+lKqXmCUiUCY= +github.com/go-quicktest/qt v1.101.0 h1:O1K29Txy5P2OK0dGo59b7b0LR6wKfIhttaAhHUyn7eI= +github.com/go-quicktest/qt v1.101.0/go.mod h1:14Bz/f7NwaXPtdYEgzsx46kqSxVwTbzVZsDC26tQJow= +github.com/go-toolsmith/astcast v1.1.0 h1:+JN9xZV1A+Re+95pgnMgDboWNVnIMMQXwfBwLRPgSC8= +github.com/go-toolsmith/astcast v1.1.0/go.mod h1:qdcuFWeGGS2xX5bLM/c3U9lewg7+Zu4mr+xPwZIB4ZU= +github.com/go-toolsmith/astcopy v1.1.0 h1:YGwBN0WM+ekI/6SS6+52zLDEf8Yvp3n2seZITCUBt5s= +github.com/go-toolsmith/astcopy v1.1.0/go.mod h1:hXM6gan18VA1T/daUEHCFcYiW8Ai1tIwIzHY6srfEAw= +github.com/go-toolsmith/astequal v1.0.3/go.mod h1:9Ai4UglvtR+4up+bAD4+hCj7iTo4m/OXVTSLnCyTAx4= +github.com/go-toolsmith/astequal v1.1.0/go.mod h1:sedf7VIdCL22LD8qIvv7Nn9MuWJruQA/ysswh64lffQ= +github.com/go-toolsmith/astequal v1.2.0 h1:3Fs3CYZ1k9Vo4FzFhwwewC3CHISHDnVUPC4x0bI2+Cw= +github.com/go-toolsmith/astequal v1.2.0/go.mod h1:c8NZ3+kSFtFY/8lPso4v8LuJjdJiUFVnSuU3s0qrrDY= +github.com/go-toolsmith/astfmt v1.1.0 h1:iJVPDPp6/7AaeLJEruMsBUlOYCmvg0MoCfJprsOmcco= +github.com/go-toolsmith/astfmt v1.1.0/go.mod h1:OrcLlRwu0CuiIBp/8b5PYF9ktGVZUjlNMV634mhwuQ4= +github.com/go-toolsmith/astp v1.1.0 h1:dXPuCl6u2llURjdPLLDxJeZInAeZ0/eZwFJmqZMnpQA= +github.com/go-toolsmith/astp v1.1.0/go.mod h1:0T1xFGz9hicKs8Z5MfAqSUitoUYS30pDMsRVIDHs8CA= +github.com/go-toolsmith/pkgload v1.2.2 h1:0CtmHq/02QhxcF7E9N5LIFcYFsMR5rdovfqTtRKkgIk= +github.com/go-toolsmith/pkgload v1.2.2/go.mod h1:R2hxLNRKuAsiXCo2i5J6ZQPhnPMOVtU+f0arbFPWCus= +github.com/go-toolsmith/strparse v1.0.0/go.mod h1:YI2nUKP9YGZnL/L1/DLFBfixrcjslWct4wyljWhSRy8= +github.com/go-toolsmith/strparse v1.1.0 h1:GAioeZUK9TGxnLS+qfdqNbA4z0SSm5zVNtCQiyP2Bvw= +github.com/go-toolsmith/strparse v1.1.0/go.mod h1:7ksGy58fsaQkGQlY8WVoBFNyEPMGuJin1rfoPS4lBSQ= +github.com/go-toolsmith/typep v1.1.0 h1:fIRYDyF+JywLfqzyhdiHzRop/GQDxxNhLGQ6gFUNHus= +github.com/go-toolsmith/typep v1.1.0/go.mod h1:fVIw+7zjdsMxDA3ITWnH1yOiw1rnTQKCsF/sk2H/qig= +github.com/gobwas/glob v0.2.3 h1:A4xDbljILXROh+kObIiy5kIaPYD8e96x1tgBhUI5J+Y= +github.com/gobwas/glob v0.2.3/go.mod h1:d3Ez4x06l9bZtSvzIay5+Yzi0fmZzPgnTbPcKjJAkT8= +github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= +github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk= +github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da h1:oI5xCqsCo564l8iNU+DwB5epxmsaqB+rhGL0m5jtYqE= github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= -github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38= -github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/google/go-cmdtest v0.4.1-0.20220921163831-55ab3332a786 h1:rcv+Ippz6RAtvaGgKxc+8FQIpxHgsF+HBzPyYL2cyVU= +github.com/google/go-cmdtest v0.4.1-0.20220921163831-55ab3332a786/go.mod h1:apVn/GCasLZUVpAJ6oWAuyP7Ne7CEsQbTnc0plM3m+o= +github.com/google/go-cmp v0.5.8/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/renameio v0.1.0 h1:GOZbcHa3HfsPKPlmyPyN2KEohoMXOhdMbHrvbpl2QaA= +github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI= +github.com/google/renameio/v2 v2.0.0 h1:UifI23ZTGY8Tt29JbYFiuyIU3eX+RNFtUwefq9qAhxg= +github.com/google/renameio/v2 v2.0.0/go.mod h1:BtmJXm5YlszgC+TD4HOEEUFgkJP3nLxehU6hfe7jRt4= +github.com/gordonklaus/ineffassign v0.1.0 h1:y2Gd/9I7MdY1oEIt+n+rowjBNDcLQq3RsH5hwJd0f9s= +github.com/gordonklaus/ineffassign v0.1.0/go.mod h1:Qcp2HIAYhR7mNUVSIxZww3Guk4it82ghYcEXIAk+QT0= +github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4= +github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ= github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 h1:BQSFePA1RWJOlocH6Fxy8MmwDt+yVQYULKfN0RoTN8A= github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99/go.mod h1:1lJo3i6rXxKeerYnT8Nvf0QmHCRC1n8sfWVwXF2Frvo= +github.com/jgautheron/goconst v1.7.0 h1:cEqH+YBKLsECnRSd4F4TK5ri8t/aXtt/qoL0Ft252B0= +github.com/jgautheron/goconst v1.7.0/go.mod h1:aAosetZ5zaeC/2EfMeRswtxUFBpe2Hr7HzkgX4fanO4= github.com/kevinburke/ssh_config v1.2.0 h1:x584FjTGwHzMwvHx18PXxbBVzfnxogHaAReU4gf13a4= github.com/kevinburke/ssh_config v1.2.0/go.mod h1:CT57kijsi8u/K/BOFA39wgDQJ9CxiF4nAY/ojJ6r6mM= +github.com/kisielk/errcheck v1.7.0 h1:+SbscKmWJ5mOK/bO1zS60F5I9WwZDWOfRsC4RwfwRV0= +github.com/kisielk/errcheck v1.7.0/go.mod h1:1kLL+jV4e+CFfueBmI1dSK2ADDyQnlrnrY/FqKluHJQ= +github.com/klauspost/compress v1.17.6 h1:60eq2E/jlfwQXtvZEeBUYADs+BwKBWURIY+Gj2eRGjI= +github.com/klauspost/compress v1.17.6/go.mod h1:/dCuZOvVtNoHsyb+cuJD3itjs3NbnF6KH9zAO4BDxPM= github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= @@ -48,60 +128,153 @@ github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= -github.com/matryer/is v1.2.0 h1:92UTHpy8CDwaJ08GqLDzhhuixiBUUD1p3AU6PHddz4A= -github.com/matryer/is v1.2.0/go.mod h1:2fLPjFQM9rhQ15aVEtbuwhJinnOqrmgXPNdZsdwlWXA= +github.com/kunwardeep/paralleltest v1.0.10 h1:wrodoaKYzS2mdNVnc4/w31YaXFtsc21PCTdvWJ/lDDs= +github.com/kunwardeep/paralleltest v1.0.10/go.mod h1:2C7s65hONVqY7Q5Efj5aLzRCNLjw2h4eMc9EcypGjcY= +github.com/liamg/memoryfs v1.6.0 h1:jAFec2HI1PgMTem5gR7UT8zi9u4BfG5jorCRlLH06W8= +github.com/liamg/memoryfs v1.6.0/go.mod h1:z7mfqXFQS8eSeBBsFjYLlxYRMRyiPktytvYCYTb3BSk= +github.com/magiconair/properties v1.8.7 h1:IeQXZAiQcpL9mgcAe1Nu6cX9LLw6ExEHKjN0VQdvPDY= +github.com/magiconair/properties v1.8.7/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0= +github.com/mdempsky/unconvert v0.0.0-20230907125504-415706980c06 h1:GC1BHRdynugzxNoEphewRqF4qcD/zzqQYsls4KXFtT8= +github.com/mdempsky/unconvert v0.0.0-20230907125504-415706980c06/go.mod h1:DuAZxNOBRkxMjbchCclLZxb/18Qb46cU26hBsomVuow= +github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= +github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= +github.com/nishanths/exhaustive v0.12.0 h1:vIY9sALmw6T/yxiASewa4TQcFsVYZQQRUQJhKRf3Swg= +github.com/nishanths/exhaustive v0.12.0/go.mod h1:mEZ95wPIZW+x8kC4TgC+9YCUgiST7ecevsVDTgc2obs= github.com/onsi/gomega v1.27.10 h1:naR28SdDFlqrG6kScpT8VWpu1xWY5nJRCF3XaYyBjhI= github.com/onsi/gomega v1.27.10/go.mod h1:RsS8tutOdbdgzbPtzzATp12yT7kM5I5aElG3evPbQ0M= +github.com/opencontainers/runtime-spec v1.2.0 h1:z97+pHb3uELt/yiAWD691HNHQIF07bE7dzrbT927iTk= +github.com/opencontainers/runtime-spec v1.2.0/go.mod h1:jwyrGlmzljRJv/Fgzds9SsS/C5hL+LL3ko9hs6T5lQ0= +github.com/pbnjay/memory v0.0.0-20210728143218-7b4eea64cf58 h1:onHthvaw9LFnH4t2DcNVpwGmV9E1BkGknEliJkfwQj0= +github.com/pbnjay/memory v0.0.0-20210728143218-7b4eea64cf58/go.mod h1:DXv8WO4yhMYhSNPKjeNKa5WY9YCIEBRbNzFFPJbWO6Y= +github.com/pelletier/go-toml/v2 v2.1.1 h1:LWAJwfNvjQZCFIDKWYQaM62NcYeYViCmWIwmOStowAI= +github.com/pelletier/go-toml/v2 v2.1.1/go.mod h1:tJU2Z3ZkXwnxa4DPO899bsyIoywizdUvyaeZurnPPDc= github.com/pjbgf/sha1cd v0.3.0 h1:4D5XXmUUBUl/xQ6IjCkEAbqXskkq/4O7LmGn0AqMDs4= github.com/pjbgf/sha1cd v0.3.0/go.mod h1:nZ1rrWOcGJ5uZgEEVL1VUM9iRQiZvWdbZjkKyFzPPsI= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= -github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/rogpeppe/go-internal v1.11.0 h1:cWPaGQEPrBb5/AsnsZesgZZ9yb1OQ+GOISoDNXVBh4M= -github.com/rogpeppe/go-internal v1.11.0/go.mod h1:ddIwULY96R17DhadqLgMfk9H9tvdUzkipdSkR5nkCZA= -github.com/sergi/go-diff v1.1.0 h1:we8PVUC3FE2uYfodKH/nBHMSetSfHDR6scGdBi+erh0= -github.com/sergi/go-diff v1.1.0/go.mod h1:STckp+ISIX8hZLjrqAeVduY0gWCT9IjLuqbuNXdaHfM= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/polyfloyd/go-errorlint v1.4.8 h1:jiEjKDH33ouFktyez7sckv6pHWif9B7SuS8cutDXFHw= +github.com/polyfloyd/go-errorlint v1.4.8/go.mod h1:NNCxFcFjZcw3xNjVdCchERkEM6Oz7wta2XJVxRftwO4= +github.com/prashantv/gostub v1.1.0 h1:BTyx3RfQjRHnUWaGF9oQos79AlQ5k8WNktv7VGvVH4g= +github.com/prashantv/gostub v1.1.0/go.mod h1:A5zLQHz7ieHGG7is6LLXLz7I8+3LZzsrV0P1IAHhP5U= +github.com/quasilyte/go-ruleguard v0.4.0 h1:DyM6r+TKL+xbKB4Nm7Afd1IQh9kEUKQs2pboWGKtvQo= +github.com/quasilyte/go-ruleguard v0.4.0/go.mod h1:Eu76Z/R8IXtViWUIHkE3p8gdH3/PKk1eh3YGfaEof10= +github.com/quasilyte/gogrep v0.5.0 h1:eTKODPXbI8ffJMN+W2aE0+oL0z/nh8/5eNdiO34SOAo= +github.com/quasilyte/gogrep v0.5.0/go.mod h1:Cm9lpz9NZjEoL1tgZ2OgeUKPIxL1meE7eo60Z6Sk+Ng= +github.com/quasilyte/regex/syntax v0.0.0-20210819130434-b3f0c404a727 h1:TCg2WBOl980XxGFEZSS6KlBGIV0diGdySzxATTWoqaU= +github.com/quasilyte/regex/syntax v0.0.0-20210819130434-b3f0c404a727/go.mod h1:rlzQ04UMyJXu/aOvhd8qT+hvDrFpiwqp8MRXDY9szc0= +github.com/quasilyte/stdinfo v0.0.0-20220114132959-f7386bf02567 h1:M8mH9eK4OUR4lu7Gd+PU1fV2/qnDNfzT635KRSObncs= +github.com/quasilyte/stdinfo v0.0.0-20220114132959-f7386bf02567/go.mod h1:DWNGW8A4Y+GyBgPuaQJuWiy0XYftx4Xm/y5Jqk9I6VQ= +github.com/remyoudompheng/go-misc v0.0.0-20190427085024-2d6ac652a50e h1:eTWZyPUnHcuGRDiryS/l2I7FfKjbU3IBx3IjqHPxuKU= +github.com/remyoudompheng/go-misc v0.0.0-20190427085024-2d6ac652a50e/go.mod h1:80FQABjoFzZ2M5uEa6FUaJYEmqU2UOKojlFVak1UAwI= +github.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8= +github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4= +github.com/sagikazarmark/locafero v0.4.0 h1:HApY1R9zGo4DBgr7dqsTH/JJxLTTsOt7u6keLGt6kNQ= +github.com/sagikazarmark/locafero v0.4.0/go.mod h1:Pe1W6UlPYUk/+wc/6KFhbORCfqzgYEpgQ3O5fPuL3H4= +github.com/sagikazarmark/slog-shim v0.1.0 h1:diDBnUNK9N/354PgrxMywXnAwEr1QZcOr6gto+ugjYE= +github.com/sagikazarmark/slog-shim v0.1.0/go.mod h1:SrcSrq8aKtyuqEI1uvTDTK1arOWRIczQRv+GVI1AkeQ= +github.com/sergi/go-diff v1.3.1 h1:xkr+Oxo4BOQKmkn/B9eMK0g5Kg/983T9DqqPHwYqD+8= +github.com/sergi/go-diff v1.3.1/go.mod h1:aMJSSKb2lpPvRNec0+w3fl7LP9IOFzdc9Pa4NFbPK1I= +github.com/sirkon/dst v0.26.4 h1:ETxfjyp5JKE8OCpdybyyhzTyQqq/MwbIIcs7kxcUAcA= +github.com/sirkon/dst v0.26.4/go.mod h1:e6HRc56jU5F2XT6GB8Cyci1Jb5cjX6gLqrm5+T/P7Zo= github.com/sirupsen/logrus v1.7.0/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0= -github.com/skeema/knownhosts v1.2.0 h1:h9r9cf0+u7wSE+M183ZtMGgOJKiL96brpaz5ekfJCpM= -github.com/skeema/knownhosts v1.2.0/go.mod h1:g4fPeYpque7P0xefxtGzV81ihjC8sX2IqpAoNkjxbMo= +github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= +github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= +github.com/skeema/knownhosts v1.2.1 h1:SHWdIUa82uGZz+F+47k8SY4QhhI291cXCpopT1lK2AQ= +github.com/skeema/knownhosts v1.2.1/go.mod h1:xYbVRSPxqBZFrdmDyMmsOs+uX1UZC3nTN3ThzgDxUwo= +github.com/sourcegraph/conc v0.3.0 h1:OQTbbt6P72L20UqAkXXuLOj79LfEanQ+YQFNpLA9ySo= +github.com/sourcegraph/conc v0.3.0/go.mod h1:Sdozi7LEKbFPqYX2/J+iBAM6HpqSLTASQIKqDmF7Mt0= +github.com/spf13/afero v1.11.0 h1:WJQKhtpdm3v2IzqG8VMqrr6Rf3UYpEF239Jy9wNepM8= +github.com/spf13/afero v1.11.0/go.mod h1:GH9Y3pIexgf1MTIWtNGyogA5MwRIDXGUr+hbWNoBjkY= +github.com/spf13/cast v1.6.0 h1:GEiTHELF+vaR5dhz3VqZfFSzZjYbgeKDpBxQVS4GYJ0= +github.com/spf13/cast v1.6.0/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo= +github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= +github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/spf13/viper v1.18.2 h1:LUXCnvUvSM6FXAsj6nnfc8Q2tp1dIgUfY9Kc8GsSOiQ= +github.com/spf13/viper v1.18.2/go.mod h1:EKmWIqdnk5lOcmR72yw6hS+8OPYcwD0jteitLMVB+yk= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/objx v0.5.1 h1:4VhoImhV/Bm0ToFkXFi8hXNXwpDRZ/ynw3amt82mzq0= +github.com/stretchr/objx v0.5.1/go.mod h1:/iHQpkQwBD6DLUmQ4pE+s1TXdob1mORJ4/UFdrifcy0= github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= -github.com/stretchr/testify v1.4.0 h1:2E4SXV/wtOkTonXsotYi4li6zVWxYlZuYNCXe9XRJyk= github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= +github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= +github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= +github.com/tetafro/godot v1.4.16 h1:4ChfhveiNLk4NveAZ9Pu2AN8QZ2nkUGFuadM9lrr5D0= +github.com/tetafro/godot v1.4.16/go.mod h1:2oVxTBSftRTh4+MVfUaUXR6bn2GDXCaMcOG4Dk3rfio= +github.com/tomarrell/wrapcheck/v2 v2.8.1 h1:HxSqDSN0sAt0yJYsrcYVoEeyM4aI9yAm3KQpIXDJRhQ= +github.com/tomarrell/wrapcheck/v2 v2.8.1/go.mod h1:/n2Q3NZ4XFT50ho6Hbxg+RV1uyo2Uow/Vdm9NQcl5SE= +github.com/ultraware/whitespace v0.1.0 h1:O1HKYoh0kIeqE8sFqZf1o0qbORXUCOQFrlaQyZsczZw= +github.com/ultraware/whitespace v0.1.0/go.mod h1:/se4r3beMFNmewJ4Xmz0nMQ941GJt+qmSHGP9emHYe0= github.com/xanzy/ssh-agent v0.3.3 h1:+/15pJfg/RsTxqYcX6fHqOXZwwMP+2VyYWJeWM2qQFM= github.com/xanzy/ssh-agent v0.3.3/go.mod h1:6dzNDKs0J9rVPHPhaGCukekBHKqfl+L3KghI1Bc68Uw= +github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= +gitlab.com/bosi/decorder v0.4.1 h1:VdsdfxhstabyhZovHafFw+9eJ6eU0d2CkFNJcZz/NU4= +gitlab.com/bosi/decorder v0.4.1/go.mod h1:jecSqWUew6Yle1pCr2eLWTensJMmsxHsBwt+PVbkAqA= +go.uber.org/automaxprocs v1.5.3 h1:kWazyxZUrS3Gs4qUpbwo5kEIMGe/DAvi5Z4tl2NW4j8= +go.uber.org/automaxprocs v1.5.3/go.mod h1:eRbA25aqJrxAbsLO0xy5jVwPt7FQnRgjW+efnwa1WM0= +go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= +go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= +go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= +go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= +go.uber.org/nilaway v0.0.0-20240216175439-fb8b98c43554 h1:7aJiOgh2uR+FWIrGtiwbKTDYf9alRmzJf7KecI/IVOs= +go.uber.org/nilaway v0.0.0-20240216175439-fb8b98c43554/go.mod h1:dXIp8cXJdluq329mTHJ7tGXLm2OjEkjUht9vOSbfREE= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= golang.org/x/crypto v0.3.1-0.20221117191849-2c476679df9a/go.mod h1:hebNnKkNXi2UzZN1eVRvBB7co0a+JxK6XbPiWVs/3J4= golang.org/x/crypto v0.7.0/go.mod h1:pYwdfH91IfpZVANVyUOhSIPZaFoJGxTFbZhFTx+dXZU= -golang.org/x/crypto v0.13.0 h1:mvySKfSWJ+UKUii46M40LOvyWfN0s2U+46/jDd0e6Ck= -golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc= +golang.org/x/crypto v0.19.0 h1:ENy+Az/9Y1vSrlrvBSyna3PITt4tiZLf7sgCjZBX7Wo= +golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU= +golang.org/x/exp v0.0.0-20240213143201-ec583247a57a h1:HinSgX1tJRX3KsL//Gxynpw5CTOAIPhgL4W8PNiIpVE= +golang.org/x/exp v0.0.0-20240213143201-ec583247a57a/go.mod h1:CxmFvTBINI24O/j8iY7H1xHzx2i4OsyguNBmN/uPtqc= +golang.org/x/exp/typeparams v0.0.0-20220428152302-39d4317da171/go.mod h1:AbB0pIl9nAr9wVwH+Z2ZpaocVmF5I4GyWCDIsVjR0bk= +golang.org/x/exp/typeparams v0.0.0-20230203172020-98cc5a0785f9/go.mod h1:AbB0pIl9nAr9wVwH+Z2ZpaocVmF5I4GyWCDIsVjR0bk= +golang.org/x/exp/typeparams v0.0.0-20240213143201-ec583247a57a h1:rrd/FiSCWtI24jk057yBSfEfHrzzjXva1VkDNWRXMag= +golang.org/x/exp/typeparams v0.0.0-20240213143201-ec583247a57a/go.mod h1:AbB0pIl9nAr9wVwH+Z2ZpaocVmF5I4GyWCDIsVjR0bk= +golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= -golang.org/x/mod v0.12.0 h1:rmsUpXtvNzj340zd98LZ4KntptpfRHwpFOHG188oHXc= -golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= +golang.org/x/mod v0.15.0 h1:SernR4v+D55NyBH2QiEQrlBAnj1ECL6AGrA5+dPaMY8= +golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= +golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM= golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= golang.org/x/net v0.2.0/go.mod h1:KqCZLdyyvdV855qA2rE3GC2aiw5xGR5TEjj8smXukLY= golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= golang.org/x/net v0.8.0/go.mod h1:QVkue5JL9kW//ek3r6jTKnTFis1tRmNAW2P1shuFdJc= -golang.org/x/net v0.15.0 h1:ugBLEUaxABaB5AJqW9enI0ACdci2RUd4eP51NTBvuJ8= -golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk= +golang.org/x/net v0.21.0 h1:AQyQV4dYCvJ7vGmJyKki9+PBdyvhkSd8EIx/qb0AYv4= +golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44= +golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.3.0 h1:ftCYgMx6zT/asHUrPw8BLLscYtGznsLAnjq5RH9P66E= -golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y= +golang.org/x/sync v0.6.0 h1:5BMeUDZ7vkXGfEr1x9B4bRcTH4lpkTkpdh0T/J+qjbQ= +golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210510120138-977fb7262007/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= @@ -110,15 +283,15 @@ golang.org/x/sys v0.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.3.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.12.0 h1:CM0HF96J0hcLAwsHPJZjfdNzs0gftsLfgKt57wWHJ0o= -golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.17.0 h1:25cE3gD+tdBA7lp7QfhuV+rJiE9YXTcS3VG1SqssI/Y= +golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.2.0/go.mod h1:TVmDHMZPmdnySmBfhjOoOdhjzdE1h4u1VwSiw2l1Nuc= golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= golang.org/x/term v0.6.0/go.mod h1:m6U89DPEgQRMq3DNkDClhWw02AUbt2daBVO4cn4Hv9U= -golang.org/x/term v0.12.0 h1:/ZfYdc3zq+q02Rv9vGqTeSItdzZTSNDmfTi0mBAuidU= -golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU= +golang.org/x/term v0.17.0 h1:mkTF7LCd6WGJNL3K1Ad7kwxNfYAW6a8a8QqtMblp/4U= +golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= @@ -126,23 +299,41 @@ golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.8.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= -golang.org/x/text v0.13.0 h1:ablQoSUd0tRdKxZewP80B+BaqeKJuVhuRxj/dkrun3k= -golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= +golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ= +golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20190228203856-589c23e65e65/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.1.5/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= -golang.org/x/tools v0.13.0 h1:Iey4qkscZuv0VvIt8E0neZjtPVQFSc870HQ448QgEmQ= -golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58= +golang.org/x/tools v0.18.0 h1:k8NLag8AGHnn+PHbl7g43CtqZAwG60vZkLqgyZgIHgQ= +golang.org/x/tools v0.18.0/go.mod h1:GL7B4CwcLLeo59yx/9UWWuNOW1n3VZ4f5axWfML7Lcg= +golang.org/x/vuln v1.0.4 h1:SP0mPeg2PmGCu03V+61EcQiOjmpri2XijexKdzv8Z1I= +golang.org/x/vuln v1.0.4/go.mod h1:NbJdUQhX8jY++FtuhrXs2Eyx0yePo9pF7nPlIjo9aaQ= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= +google.golang.org/protobuf v1.32.0 h1:pPC6BG5ex8PDFnkbrGU3EixyhKcQ2aDuBS36lqK/C7I= +google.golang.org/protobuf v1.32.0/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA= +gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= gopkg.in/warnings.v0 v0.1.2 h1:wFXVbFY8DY5/xOe1ECiWdKCzZlxgshcYVNkBHstARME= gopkg.in/warnings.v0 v0.1.2/go.mod h1:jksf8JmL6Qr/oQM2OXTHunEvvTAsrWBLb6OOjuVWRNI= gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= -gopkg.in/yaml.v2 v2.2.4 h1:/eiJrUcujPVeJ3xlSWaiNi3uSVmDGBK1pDHUHAnao1I= -gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= +gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gotest.tools/v3 v3.5.1 h1:EENdUnS3pdur5nybKYIh2Vfgc8IUNBjxDPSjtiJcOzU= +gotest.tools/v3 v3.5.1/go.mod h1:isy3WKz7GK6uNw/sbHzfKBLvlvXwUyV06n6brMxxopU= +honnef.co/go/tools v0.4.6 h1:oFEHCKeID7to/3autwsWfnuv69j3NsfcXbvJKuIcep8= +honnef.co/go/tools v0.4.6/go.mod h1:+rnGS1THNh8zMwnd2oVOTL9QF6vmfyG6ZXBULae2uc0= +mvdan.cc/unparam v0.0.0-20240104100049-c549a3470d14 h1:zCr3iRRgdk5eIikZNDphGcM6KGVTx3Yu+/Uu9Es254w= +mvdan.cc/unparam v0.0.0-20240104100049-c549a3470d14/go.mod h1:ZzZjEpJDOmx8TdVU6umamY3Xy0UAQUI2DHbf05USVbI= diff --git a/internal/cache/cache.go b/internal/cache/cache.go new file mode 100644 index 0000000..2b3dbff --- /dev/null +++ b/internal/cache/cache.go @@ -0,0 +1,98 @@ +// Copyright 2024 Eric Cornelissen +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package cache + +import ( + "fmt" + "os" + "path" +) + +// Cache represents a cache located on the file system. +type Cache struct { + // Path is the path of the cache on the file system. + path string + + // Ephemeral marks the cache as such, locating it in the system's temporary + // directory and + ephemeral bool +} + +// Cleanup removes the cache if it is ephemeral, ignoring errors. +func (c *Cache) Cleanup() { + if c.ephemeral { + _ = c.Clear() + } +} + +// Clear removes the contents of the cache. +func (c *Cache) Clear() error { + if err := os.RemoveAll(c.path); err != nil { + return fmt.Errorf("could not clear %q: %v", c.path, err) + } + + return nil +} + +// Init sets up the cache (if necessary). +func (c *Cache) Init() error { + if c.ephemeral { + location, err := os.MkdirTemp(os.TempDir(), "ghasum-clone-*") + if err != nil { + return fmt.Errorf("could not create temporary cache: %v", err) + } + + c.path = location + } else { + if err := os.MkdirAll(c.path, 0o700); err != nil { + return fmt.Errorf("could not create cache at %q: %v", c.path, err) + } + } + + return nil +} + +// Path returns the path to the cache on the file system. +func (c *Cache) Path() string { + return c.path +} + +// New creates an uninitialized cache. +// +// If location is an empty string the location will default to the user's home +// directory. +// +// If ephemeral is set the cache will be located in a unique directory in the +// system's temporary directory (and the given location is ignored). +func New(location string, ephemeral bool) (Cache, error) { + var c Cache + + if ephemeral { + c.ephemeral = true + } else { + if location == "" { + home, err := os.UserHomeDir() + if err != nil { + return c, fmt.Errorf("could not get user home directory: %v", err) + } + + c.path = path.Join(home, ".ghasum") + } else { + c.path = location + } + } + + return c, nil +} diff --git a/internal/cache/doc.go b/internal/cache/doc.go new file mode 100644 index 0000000..e529ab2 --- /dev/null +++ b/internal/cache/doc.go @@ -0,0 +1,17 @@ +// Copyright 2024 Eric Cornelissen +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// Package cache provides functionality for managing a cache located on the +// file system. +package cache diff --git a/internal/checksum/checksum.go b/internal/checksum/checksum.go new file mode 100644 index 0000000..aa8c940 --- /dev/null +++ b/internal/checksum/checksum.go @@ -0,0 +1,48 @@ +// Copyright 2024 Eric Cornelissen +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package checksum + +import ( + "fmt" + + "github.com/rogpeppe/go-internal/dirhash" +) + +// Algo represents a cryptographic hash algorithm. +type Algo int + +const ( + // Sha256 identifies the SHA256 hashing algorithm. + Sha256 Algo = iota + + // BestAlgo identifies the best available hashing algorithm. + BestAlgo = Sha256 +) + +var hashes = map[Algo]dirhash.Hash{ + Sha256: dirhash.Hash1, +} + +// Compute the checksum over the directory at the given path using the specified +// cryptographic hash algorithm. +func Compute(path string, algo Algo) (string, error) { + hash := hashes[algo] + checksum, err := dirhash.HashDir(path, "", hash) + if err != nil { + return "", fmt.Errorf("could not compute checksum: %v", err) + } + + return checksum, nil +} diff --git a/internal/checksum/checksum_test.go b/internal/checksum/checksum_test.go new file mode 100644 index 0000000..8e15c3b --- /dev/null +++ b/internal/checksum/checksum_test.go @@ -0,0 +1,34 @@ +// Copyright 2024 Eric Cornelissen +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package checksum + +import ( + "testing" +) + +func TestExitCodes(t *testing.T) { + t.Parallel() + + algos := []Algo{ + Sha256, + BestAlgo, + } + + for _, algo := range algos { + if _, ok := hashes[algo]; !ok { + t.Errorf("Missing algorithm %d from the hashes map", algo) + } + } +} diff --git a/internal/checksum/doc.go b/internal/checksum/doc.go new file mode 100644 index 0000000..1ebfac4 --- /dev/null +++ b/internal/checksum/doc.go @@ -0,0 +1,16 @@ +// Copyright 2024 Eric Cornelissen +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// Package checksum provides functionality for computing checksums. +package checksum diff --git a/internal/gha/actions.go b/internal/gha/actions.go new file mode 100644 index 0000000..075787b --- /dev/null +++ b/internal/gha/actions.go @@ -0,0 +1,89 @@ +// Copyright 2024 Eric Cornelissen +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package gha + +import ( + "fmt" + "io" + "io/fs" + "path" +) + +func actionsInWorkflows(workflows []workflow) ([]GitHubAction, error) { + unique := make(map[string]GitHubAction, 0) + for _, workflow := range workflows { + for _, job := range workflow.Jobs { + for _, step := range job.Steps { + uses := step.Uses + if uses == "" { + continue + } + + action, err := parseUses(uses) + if err != nil { + return nil, err + } + + id := fmt.Sprintf("%s%s%s", action.Owner, action.Project, action.Ref) + unique[id] = action + } + } + } + + i := 0 + actions := make([]GitHubAction, len(unique)) + for _, action := range unique { + actions[i] = action + i++ + } + + return actions, nil +} + +func workflowsInRepo(repo fs.FS) ([][]byte, error) { + workflows := make([][]byte, 0) + walk := func(entryPath string, entry fs.DirEntry, err error) error { + if err != nil { + return err + } + + if entry.IsDir() { + if entryPath == WorkflowsPath { + return nil + } else { + return fs.SkipDir + } + } + + if ext := path.Ext(entryPath); ext != ".yml" && ext != ".yaml" { + return nil + } + + file, err := repo.Open(entryPath) + if err != nil { + return fmt.Errorf("could not open workflow at %q: %v", entryPath, err) + } + + data, _ := io.ReadAll(file) + workflows = append(workflows, data) + return nil + } + + if err := fs.WalkDir(repo, WorkflowsPath, walk); err != nil { + return nil, fmt.Errorf("failed to find workflows: %v", err) + } + + return workflows, nil +} diff --git a/internal/gha/actions_test.go b/internal/gha/actions_test.go new file mode 100644 index 0000000..ca0c9ee --- /dev/null +++ b/internal/gha/actions_test.go @@ -0,0 +1,401 @@ +// Copyright 2024 Eric Cornelissen +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package gha + +import ( + "bytes" + "testing" + "testing/quick" + + "github.com/liamg/memoryfs" +) + +func TestActionsInWorkflows(t *testing.T) { + t.Parallel() + + t.Run("Valid examples", func(t *testing.T) { + t.Parallel() + + type TestCase struct { + name string + in []workflow + want int + } + + testCases := []TestCase{ + { + name: "no jobs", + in: []workflow{ + { + Jobs: map[string]job{}, + }, + }, + want: 0, + }, + { + name: "one job without steps", + in: []workflow{ + { + Jobs: map[string]job{ + "example": { + Steps: []step{}, + }, + }, + }, + }, + want: 0, + }, + { + name: "multiple jobs without steps", + in: []workflow{ + { + Jobs: map[string]job{ + "example-a": { + Steps: []step{}, + }, + "example-b": { + Steps: []step{}, + }, + }, + }, + }, + want: 0, + }, + { + name: "one job with a step without uses", + in: []workflow{ + { + Jobs: map[string]job{ + "example": { + Steps: []step{ + {}, + }, + }, + }, + }, + }, + want: 0, + }, + { + name: "one job with one step", + in: []workflow{ + { + Jobs: map[string]job{ + "example": { + Steps: []step{ + { + Uses: "foo/bar@v1", + }, + }, + }, + }, + }, + }, + want: 1, + }, + { + name: "multiple jobs with one unique step each", + in: []workflow{ + { + Jobs: map[string]job{ + "example-a": { + Steps: []step{ + { + Uses: "foo/bar@v1", + }, + }, + }, + "example-b": { + Steps: []step{ + { + Uses: "foo/baz@v1", + }, + }, + }, + }, + }, + }, + want: 2, + }, + { + name: "one job with multiple unique steps", + in: []workflow{ + { + Jobs: map[string]job{ + "example": { + Steps: []step{ + { + Uses: "foo/bar@v1", + }, + { + Uses: "foo/baz@v2", + }, + }, + }, + }, + }, + }, + want: 2, + }, + { + name: "multiple jobs with multiple unique steps", + in: []workflow{ + { + Jobs: map[string]job{ + "example-a": { + Steps: []step{ + { + Uses: "foo/bar@v1", + }, + { + Uses: "hello/world@v2", + }, + }, + }, + "example-b": { + Steps: []step{ + { + Uses: "foo/baz@v1", + }, + { + Uses: "hallo/wereld@v2", + }, + }, + }, + }, + }, + }, + want: 4, + }, + { + name: "one jobs with duplicate steps", + in: []workflow{ + { + Jobs: map[string]job{ + "example": { + Steps: []step{ + { + Uses: "foo/bar@v1", + }, + { + Uses: "foo/bar@v1", + }, + }, + }, + }, + }, + }, + want: 1, + }, + { + name: "multiple jobs with duplicate step between them", + in: []workflow{ + { + + Jobs: map[string]job{ + "example-a": { + Steps: []step{ + { + Uses: "foo/bar@v1", + }, + }, + }, + "example-b": { + Steps: []step{ + { + Uses: "foo/bar@v1", + }, + }, + }, + }, + }, + }, + want: 1, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + got, err := actionsInWorkflows(tc.in) + if err != nil { + t.Fatalf("Unexpected error: %+v", err) + } + + if got, want := len(got), tc.want; got != want { + t.Errorf("Incorrect result length (got %d, want %d)", got, want) + } + }) + } + }) + + t.Run("Invalid examples", func(t *testing.T) { + t.Parallel() + + type TestCase struct { + name string + in []workflow + } + + testCases := []TestCase{ + { + name: "invalid uses value", + in: []workflow{ + { + Jobs: map[string]job{ + "example": { + Steps: []step{ + { + Uses: "this isn't an action", + }, + }, + }, + }, + }, + }, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + if _, err := actionsInWorkflows(tc.in); err == nil { + t.Fatal("Unexpected success") + } + }) + } + }) + + t.Run("Arbitrary", func(t *testing.T) { + t.Parallel() + + unique := func(workflows []workflow) bool { + actions, err := actionsInWorkflows(workflows) + if err != nil { + return true + } + + seen := make(map[GitHubAction]struct{}, 0) + for _, action := range actions { + if _, ok := seen[action]; ok { + return false + } + + seen[action] = struct{}{} + } + + return true + } + + if err := quick.Check(unique, nil); err != nil { + t.Errorf("Duplicate value detected for: %v", err) + } + }) +} + +func TestWorkflowsInRepo(t *testing.T) { + t.Parallel() + + t.Run("Valid examples", func(t *testing.T) { + t.Parallel() + + type TestCase struct { + name string + workflows map[string]mockFsEntry + want [][]byte + } + + testCases := []TestCase{ + { + name: ".yml workflow", + workflows: map[string]mockFsEntry{ + "example.yml": { + Content: []byte(workflowWithJobsWithSteps), + }, + }, + want: [][]byte{ + []byte(workflowWithJobsWithSteps), + }, + }, + { + name: ".yaml workflow", + workflows: map[string]mockFsEntry{ + "example.yaml": { + Content: []byte(workflowWithJobsWithSteps), + }, + }, + want: [][]byte{ + []byte(workflowWithJobsWithSteps), + }, + }, + { + name: "non-workflow file", + workflows: map[string]mockFsEntry{ + "greeting.txt": { + Content: []byte("Hello world!"), + }, + }, + want: [][]byte{}, + }, + { + name: "nested directory", + workflows: map[string]mockFsEntry{ + "greeting.txt": { + Dir: true, + Children: map[string]mockFsEntry{ + "workflow.yml": {}, + }, + }, + }, + want: [][]byte{}, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + repo, err := mockRepo(tc.workflows) + if err != nil { + t.Fatalf("Could not initialize file system: %+v", err) + } + + got, err := workflowsInRepo(repo) + if err != nil { + t.Fatalf("Unexpected error: %+v", err) + } + + if got, want := len(got), len(tc.want); got != want { + t.Fatalf("Incorrect result length (got %d, want %d)", got, want) + } + + for i, got := range got { + if want := tc.want[i]; !bytes.Equal(got, want) { + t.Errorf("Incorrect workflow %d (got %s, want %s)", i, got, want) + } + } + }) + } + }) + + t.Run("No actions", func(t *testing.T) { + t.Parallel() + + repo := memoryfs.New() + if _, err := workflowsInRepo(repo); err == nil { + t.Fatal("Unexpected success") + } + }) +} diff --git a/internal/gha/doc.go b/internal/gha/doc.go new file mode 100644 index 0000000..9c297b7 --- /dev/null +++ b/internal/gha/doc.go @@ -0,0 +1,16 @@ +// Copyright 2024 Eric Cornelissen +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// Package gha provides functionality for working with GitHub Actions. +package gha diff --git a/internal/gha/gha.go b/internal/gha/gha.go new file mode 100644 index 0000000..fbd6441 --- /dev/null +++ b/internal/gha/gha.go @@ -0,0 +1,64 @@ +// Copyright 2024 Eric Cornelissen +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package gha + +import ( + "io/fs" + "path" +) + +// A GitHubAction identifies a specific version of a GitHub Action. +type GitHubAction struct { + // Owner is the GitHub user or organization that owns the repository that + // houses the GitHub Action. + Owner string + + // Project is the name of the GitHub repository (excluding the owner) that + // houses the GitHub Action. + Project string + + // Ref is the git ref (branch, tag, commit SHA), also known as version, of the + // GitHub Action. + Ref string +} + +// WorkflowsPath is the relative path to the GitHub Actions workflow directory. +var WorkflowsPath = path.Join(".github", "workflows") + +// RepoActions extracts the GitHub RepoActions used in the repository at the +// given file system hierarchy. +func RepoActions(repo fs.FS) ([]GitHubAction, error) { + rawWorkflows, err := workflowsInRepo(repo) + if err != nil { + return nil, err + } + + workflows := make([]workflow, len(rawWorkflows)) + for i, rawWorkflow := range rawWorkflows { + w, parseErr := parseWorkflow(rawWorkflow) + if parseErr != nil { + return nil, parseErr + } + + workflows[i] = w + } + + actions, err := actionsInWorkflows(workflows) + if err != nil { + return nil, err + } + + return actions, nil +} diff --git a/internal/gha/gha_test.go b/internal/gha/gha_test.go new file mode 100644 index 0000000..c586cd6 --- /dev/null +++ b/internal/gha/gha_test.go @@ -0,0 +1,146 @@ +// Copyright 2024 Eric Cornelissen +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package gha + +import ( + "slices" + "testing" + + "github.com/liamg/memoryfs" +) + +func TestNoWorkflows(t *testing.T) { + t.Parallel() + + repo := memoryfs.New() + if _, err := RepoActions(repo); err == nil { + t.Fatal("Unexpected success") + } +} + +func TestFaultyWorkflow(t *testing.T) { + t.Parallel() + + workflows := map[string]mockFsEntry{ + "workflow.yaml": { + Content: []byte(workflowWithJobWithSteps), + }, + "syntax-error.yml": { + Content: []byte(workflowWithSyntaxError), + }, + } + + repo, err := mockRepo(workflows) + if err != nil { + t.Fatalf("Could not initialize file system: %+v", err) + } + + if _, err := RepoActions(repo); err == nil { + t.Fatal("Unexpected success") + } +} + +func TestFaultyUses(t *testing.T) { + t.Parallel() + + workflows := map[string]mockFsEntry{ + "workflow.yaml": { + Content: []byte(workflowWithJobWithSteps), + }, + "invalid-uses.yml": { + Content: []byte(workflowWithInvalidUses), + }, + } + + repo, err := mockRepo(workflows) + if err != nil { + t.Fatalf("Could not initialize file system: %+v", err) + } + + if _, err := RepoActions(repo); err == nil { + t.Fatal("Unexpected success") + } +} + +func TestRealisticRepository(t *testing.T) { + t.Parallel() + + workflows := map[string]mockFsEntry{ + "nested": { + Dir: true, + Children: map[string]mockFsEntry{ + "foo.bar": { + Content: []byte("foobar"), + }, + }, + }, + "not-a-workflow.txt": { + Content: []byte("Hello world!"), + }, + "one-job.yaml": { + Content: []byte(workflowWithJobWithSteps), + }, + "multiple-jobs.yml": { + Content: []byte(workflowWithJobsWithSteps), + }, + "nested-action.yml": { + Content: []byte(workflowWithNestedActions), + }, + } + + repo, err := mockRepo(workflows) + if err != nil { + t.Fatalf("Could not initialize file system: %+v", err) + } + + got, err := RepoActions(repo) + if err != nil { + t.Fatalf("Unexpected error: %+v", err) + } + + want := []GitHubAction{ + { + Owner: "foo", + Project: "bar", + Ref: "v1", + }, + { + Owner: "foo", + Project: "baz", + Ref: "v2", + }, + { + Owner: "nested", + Project: "action", + Ref: "v1", + }, + } + + if got, want := len(got), len(want); got != want { + t.Errorf("Incorrect result length (got %d, want %d)", got, want) + } + + for _, got := range got { + if !slices.Contains(want, got) { + t.Errorf("Unwanted value found %v", got) + } + } + + for _, want := range want { + if !slices.Contains(got, want) { + t.Errorf("Wanted value missing %v", want) + } + } +} diff --git a/internal/gha/parse.go b/internal/gha/parse.go new file mode 100644 index 0000000..d102b3f --- /dev/null +++ b/internal/gha/parse.go @@ -0,0 +1,79 @@ +// Copyright 2024 Eric Cornelissen +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package gha + +import ( + "errors" + "fmt" + "strings" + + "gopkg.in/yaml.v2" +) + +type ( + workflow struct { + Jobs map[string]job `yaml:"jobs"` + } + + job struct { + Steps []step `yaml:"steps"` + } + + step struct { + Uses string `yaml:"uses"` + } +) + +func parseWorkflow(data []byte) (workflow, error) { + var w workflow + if err := yaml.Unmarshal(data, &w); err != nil { + return w, fmt.Errorf("could not parse workflow: %v", err) + } + + return w, nil +} + +func parseUses(uses string) (GitHubAction, error) { + var a GitHubAction + + // split "uses" into "repo"@"ref" + i := strings.IndexRune(uses, '@') + if i <= 0 || i == len(uses)-1 { + return a, errors.New("invalid uses value") + } + + repo := uses[:i] + a.Ref = uses[i+1:] + + // split "repo" into "owner"/"project[/path]" + i = strings.IndexRune(repo, '/') + if i <= 0 || i == len(repo)-1 { + return a, errors.New("invalid repository in uses") + } + + a.Owner = repo[:i] + project := repo[i+1:] + + // split "project" into "project"[/"path"] + i = strings.IndexRune(project, '/') + if i == 0 || i == len(project)-1 { + return a, errors.New("invalid repository path in uses") + } else if i > 0 && i < len(project)-1 { + project = project[:i] + } + + a.Project = project + return a, nil +} diff --git a/internal/gha/parse_test.go b/internal/gha/parse_test.go new file mode 100644 index 0000000..cd07a69 --- /dev/null +++ b/internal/gha/parse_test.go @@ -0,0 +1,338 @@ +// Copyright 2024 Eric Cornelissen +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package gha + +import ( + "fmt" + "strings" + "testing" + "testing/quick" +) + +func TestParseUses(t *testing.T) { + t.Parallel() + + t.Run("Valid examples", func(t *testing.T) { + t.Parallel() + + type TestCase struct { + in string + want GitHubAction + } + + testCases := []TestCase{ + { + in: "foo/bar@v1", + want: GitHubAction{ + Owner: "foo", + Project: "bar", + Ref: "v1", + }, + }, + { + in: "foo/baz@v3.1.4", + want: GitHubAction{ + Owner: "foo", + Project: "baz", + Ref: "v3.1.4", + }, + }, + { + in: "hello/world@random-ref", + want: GitHubAction{ + Owner: "hello", + Project: "world", + Ref: "random-ref", + }, + }, + { + in: "hallo/wereld@35dd46a3b3dfbb14198f8d19fb083ce0832dce4a", + want: GitHubAction{ + Owner: "hallo", + Project: "wereld", + Ref: "35dd46a3b3dfbb14198f8d19fb083ce0832dce4a", + }, + }, + { + in: "foo/bar/baz@v2", + want: GitHubAction{ + Owner: "foo", + Project: "bar", + Ref: "v2", + }, + }, + } + + for _, tc := range testCases { + t.Run(tc.in, func(t *testing.T) { + t.Parallel() + + got, err := parseUses(tc.in) + if err != nil { + t.Fatalf("Unexpected error: %+v", err) + } + + if got, want := got.Owner, tc.want.Owner; got != want { + t.Errorf("Incorrect owner (got %q, want %q)", got, want) + } + + if got, want := got.Project, tc.want.Project; got != want { + t.Errorf("Incorrect project (got %q, want %q)", got, want) + } + + if got, want := got.Ref, tc.want.Ref; got != want { + t.Errorf("Incorrect ref (got %q, want %q)", got, want) + } + }) + } + }) + + t.Run("Invalid examples", func(t *testing.T) { + t.Parallel() + + type TestCase struct { + in string + want string + } + + testCases := []TestCase{ + { + in: "foobar", + want: "invalid uses value", + }, + { + in: "foo/bar", + want: "invalid uses value", + }, + { + in: "foo@bar", + want: "invalid repository in uses", + }, + { + in: "foo/@bar", + want: "invalid repository in uses", + }, + { + in: "foo/bar/@baz", + want: "invalid repository path in uses", + }, + { + in: "foo//@bar", + want: "invalid repository path in uses", + }, + { + in: "foo//bar@baz", + want: "invalid repository path in uses", + }, + } + + for _, tc := range testCases { + t.Run(tc.in, func(t *testing.T) { + t.Parallel() + + _, err := parseUses(tc.in) + if err == nil { + t.Fatal("Unexpected success") + } + + if got, want := err.Error(), tc.want; got != want { + t.Errorf("Incorrect error message (got %q, want %q)", got, want) + } + }) + } + }) + + t.Run("Arbitrary values", func(t *testing.T) { + t.Parallel() + + constructive := func(owner, project, path, ref string) bool { + if len(owner) == 0 || len(project) == 0 || len(ref) == 0 { + return true + } + + if len(path) > 0 { + path = "/" + path + } + + repo := fmt.Sprintf("%s/%s", owner, project) + if strings.Count(repo, "/") != 1 { + return true + } + + uses := fmt.Sprintf("%s%s@%s", repo, path, ref) + + action, err := parseUses(uses) + if err != nil { + return false + } + + return action.Owner == owner && action.Project == project && action.Ref == ref + } + + if err := quick.Check(constructive, nil); err != nil { + t.Errorf("Parsing failed for: %v", err) + } + + noPanic := func(uses string) bool { + _, _ = parseUses(uses) + return true + } + + if err := quick.Check(noPanic, nil); err != nil { + t.Errorf("Parsing failed for: %v", err) + } + }) +} + +func TestParseWorkflow(t *testing.T) { + t.Parallel() + + t.Run("Valid examples", func(t *testing.T) { + t.Parallel() + + type TestCase struct { + in string + want workflow + } + + testCases := []TestCase{ + { + in: workflowWithNoJobs, + want: workflow{ + Jobs: map[string]job{}, + }, + }, + { + in: workflowWithJobNoSteps, + want: workflow{ + Jobs: map[string]job{ + "no-steps": {}, + }, + }, + }, + { + in: workflowWithJobWithSteps, + want: workflow{ + Jobs: map[string]job{ + "only-job": { + Steps: []step{ + { + Uses: "foo/bar@v1", + }, + { + Uses: "", + }, + { + Uses: "foo/baz@v2", + }, + }, + }, + }, + }, + }, + { + in: workflowWithJobsWithSteps, + want: workflow{ + Jobs: map[string]job{ + "job-a": { + Steps: []step{ + { + Uses: "foo/bar@v1", + }, + }, + }, + "job-b": { + Steps: []step{ + { + Uses: "", + }, + { + Uses: "foo/baz@v2", + }, + }, + }, + }, + }, + }, + } + + for _, tc := range testCases { + t.Run(strings.Split(tc.in, "\n")[0], func(t *testing.T) { + t.Parallel() + + got, err := parseWorkflow([]byte(tc.in)) + if err != nil { + t.Fatalf("Unexpected error: %+v", err) + } + + if got, want := len(got.Jobs), len(tc.want.Jobs); got != want { + t.Fatalf("Incorrect jobs length (got %d, want %d)", got, want) + } + + for name, job := range got.Jobs { + want, ok := tc.want.Jobs[name] + if !ok { + t.Errorf("Got unwanted job %q", name) + continue + } + + if got, want := len(job.Steps), len(want.Steps); got != want { + t.Errorf("Incorrect steps length for job %q (got %d, want %d)", name, got, want) + continue + } + + for i, step := range job.Steps { + want := want.Steps[i] + + if got, want := step.Uses, want.Uses; got != want { + t.Errorf("Incorrect uses for step %d of job %q (got %q, want %q)", i, name, got, want) + } + } + } + }) + } + }) + + t.Run("Invalid examples", func(t *testing.T) { + t.Parallel() + + cases := []string{ + workflowWithSyntaxError, + } + + for _, tc := range cases { + t.Run(tc, func(t *testing.T) { + t.Parallel() + + if _, err := parseWorkflow([]byte(tc)); err == nil { + t.Fatal("Unexpected success") + } + }) + } + }) + + t.Run("Arbitrary values", func(t *testing.T) { + t.Parallel() + + noPanic := func(w []byte) bool { + _, _ = parseWorkflow(w) + return true + } + + if err := quick.Check(noPanic, nil); err != nil { + t.Errorf("Parsing failed for: %v", err) + } + }) +} diff --git a/internal/gha/shared_test.go b/internal/gha/shared_test.go new file mode 100644 index 0000000..9ea493b --- /dev/null +++ b/internal/gha/shared_test.go @@ -0,0 +1,108 @@ +// Copyright 2024 Eric Cornelissen +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package gha + +import ( + "fmt" + "io/fs" + "path" + + "github.com/liamg/memoryfs" +) + +type mockFsEntry struct { + /* File */ + Content []byte + + /* Directory */ + Dir bool + Children map[string]mockFsEntry +} + +const ( + workflowWithNoJobs = `name: no jobs` + workflowWithJobNoSteps = `name: job without steps +jobs: + no-steps: ~ +` + workflowWithJobWithSteps = `name: job with steps +jobs: + only-job: + steps: + - uses: foo/bar@v1 + - name: no uses + - uses: foo/baz@v2 +` + workflowWithJobsWithSteps = `name: jobs with steps +jobs: + job-a: + steps: + - uses: foo/bar@v1 + job-b: + steps: + - name: no uses + - uses: foo/baz@v2 +` + workflowWithNestedActions = `name: job using an action that is not at the root +jobs: + only-job: + steps: + - uses: nested/action/1@v1 + - uses: nested/action/2@v1 +` + workflowWithSyntaxError = `Hello world!` + workflowWithInvalidUses = `name: invalid 'uses' value +jobs: + job: + steps: + - uses: this-is-not-an-action +` +) + +func mockRepo(entries map[string]mockFsEntry) (fs.FS, error) { + repo := memoryfs.New() + + err := repo.MkdirAll(WorkflowsPath, 0o700) + if err != nil { + return nil, fmt.Errorf("failed to initialize workflows directory: %v", err) + } + + err = mockRepoInternal(repo, WorkflowsPath, entries) + return repo, err +} + +func mockRepoInternal(fsys *memoryfs.FS, base string, entries map[string]mockFsEntry) error { + for name, entry := range entries { + entryPath := path.Join(base, name) + if entry.Dir { + err := fsys.MkdirAll(entryPath, 0o700) + if err != nil { + return fmt.Errorf("failed to create dir %q: %v", entryPath, err) + } + + err = mockRepoInternal(fsys, entryPath, entry.Children) + if err != nil { + return err + } + } else { + err := fsys.WriteFile(entryPath, entry.Content, 0o600) + if err != nil { + return fmt.Errorf("failed to create %q: %v", entryPath, err) + } + } + } + + return nil +} diff --git a/internal/ghasum/atoms.go b/internal/ghasum/atoms.go new file mode 100644 index 0000000..6eb75da --- /dev/null +++ b/internal/ghasum/atoms.go @@ -0,0 +1,215 @@ +// Copyright 2024 Eric Cornelissen +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package ghasum + +import ( + "errors" + "fmt" + "io/fs" + "os" + "path" + "strings" + + "github.com/ericcornelissen/ghasum/internal/checksum" + "github.com/ericcornelissen/ghasum/internal/gha" + "github.com/ericcornelissen/ghasum/internal/github" + "github.com/ericcornelissen/ghasum/internal/sumfile" +) + +var ghasumPath = path.Join(gha.WorkflowsPath, "gha.sum") + +func clear(file *os.File) error { + if _, err := file.Seek(0, 0); err != nil { + return errors.Join(ErrSumfileWrite, err) + } + + if err := file.Truncate(0); err != nil { + return errors.Join(ErrSumfileWrite, err) + } + + return nil +} + +func compare(got, want []sumfile.Entry) []Problem { + toMap := func(entries []sumfile.Entry) map[string]string { + m := make(map[string]string, len(entries)) + for _, entry := range entries { + key := fmt.Sprintf("%s@%s", entry.ID[0], entry.ID[1]) + m[key] = entry.Checksum + } + + return m + } + + cmp := func(got, want map[string]string) []Problem { + problems := make([]Problem, 0) + for key, got := range got { + want, ok := want[key] + if !ok { + p := fmt.Sprintf("no checksum found for %q", key) + problems = append(problems, Problem(p)) + continue + } + + if got != want { + p := fmt.Sprintf("checksum mismatch for %q", key) + problems = append(problems, Problem(p)) + } + } + + return problems + } + + return cmp(toMap(got), toMap(want)) +} + +func compute(cfg *Config, algo checksum.Algo) ([]sumfile.Entry, error) { + actions, err := gha.RepoActions(cfg.Repo) + if err != nil { + return nil, fmt.Errorf("could not find GitHub Actions: %v", err) + } + + if err := cfg.Cache.Init(); err != nil { + return nil, fmt.Errorf("could not initialize cache: %v", err) + } else { + defer cfg.Cache.Cleanup() + } + + entries := make([]sumfile.Entry, len(actions)) + for i, action := range actions { + repo := github.Repository{ + Owner: action.Owner, + Project: action.Project, + Ref: action.Ref, + } + + actionDir := path.Join(cfg.Cache.Path(), repo.Owner, repo.Project, repo.Ref) + if _, err := os.Stat(actionDir); err != nil { + err := github.Clone(actionDir, &repo) + if err != nil { + return nil, fmt.Errorf("clone failed: %v", err) + } + } + + // checksum, err := dirhash.HashDir(actionDir, "", hashes[algo]) + checksum, err := checksum.Compute(actionDir, algo) + if err != nil { + return nil, fmt.Errorf("could not compute checksum for %q: %v", action, err) + } + + entries[i] = sumfile.Entry{ + ID: []string{fmt.Sprintf("%s/%s", repo.Owner, repo.Project), action.Ref}, + Checksum: strings.Replace(checksum, "h1:", "", 1), + } + } + + return entries, nil +} + +func create(base string) (*os.File, error) { + fullGhasumPath := path.Join(base, ghasumPath) + + if _, err := os.Stat(fullGhasumPath); err == nil { + return nil, ErrInitialized + } + + file, err := os.OpenFile(fullGhasumPath, os.O_CREATE|os.O_WRONLY, os.ModeExclusive) + if err != nil { + return nil, errors.Join(ErrSumfileCreate, err) + } + + return file, nil +} + +func decode(stored []byte) ([]sumfile.Entry, error) { + checksums, err := sumfile.Decode(string(stored)) + if err != nil { + return nil, errors.Join(ErrSumfileDecode, err) + } + + return checksums, nil +} + +func encode(version sumfile.Version, checksums []sumfile.Entry) (string, error) { + content, err := sumfile.Encode(version, checksums) + if err != nil { + return "", errors.Join(ErrSumfileEncode, err) + } + + return content, nil +} + +func open(base string) (*os.File, error) { + fullGhasumPath := path.Join(base, ghasumPath) + + file, err := os.OpenFile(fullGhasumPath, os.O_RDWR, os.ModeExclusive) + if errors.Is(err, fs.ErrNotExist) { + return nil, ErrNotInitialized + } else if err != nil { + return nil, errors.Join(ErrSumfileOpen, err) + } + + if err := os.Chmod(fullGhasumPath, fs.ModeExclusive); err != nil { + return file, errors.Join(ErrSumfileUnlock, err) + } + + return file, nil +} + +func read(repo fs.FS) ([]byte, error) { + raw, err := fs.ReadFile(repo, ghasumPath) + if errors.Is(err, fs.ErrNotExist) { + return nil, ErrNotInitialized + } else if err != nil { + return nil, errors.Join(ErrSumfileRead, err) + } + + return raw, nil +} + +func remove(base string) error { + fullGhasumPath := path.Join(base, ghasumPath) + if err := os.Remove(fullGhasumPath); err != nil { + return errors.Join(ErrSumfileRemove, err) + } + + return nil +} + +func unlock(base string) error { + fullGhasumPath := path.Join(base, ghasumPath) + if err := os.Chmod(fullGhasumPath, fs.ModePerm); err != nil { + return errors.Join(ErrSumfileUnlock, err) + } + + return nil +} + +func version(stored []byte) (sumfile.Version, error) { + version, err := sumfile.DecodeVersion(string(stored)) + if err != nil { + return version, errors.Join(ErrSumfileDecode, err) + } + + return version, nil +} + +func write(file *os.File, content string) error { + if _, err := file.WriteString(content); err != nil { + return errors.Join(ErrSumfileWrite, err) + } + + return nil +} diff --git a/internal/ghasum/doc.go b/internal/ghasum/doc.go new file mode 100644 index 0000000..b82b0da --- /dev/null +++ b/internal/ghasum/doc.go @@ -0,0 +1,17 @@ +// Copyright 2024 Eric Cornelissen +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// Package ghasum provides functionality for working with and manipulating +// gha.sum files. +package ghasum diff --git a/internal/ghasum/errors.go b/internal/ghasum/errors.go new file mode 100644 index 0000000..cb427b3 --- /dev/null +++ b/internal/ghasum/errors.go @@ -0,0 +1,59 @@ +// Copyright 2024 Eric Cornelissen +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package ghasum + +import "errors" + +var ( + // ErrInitialized is the error used when ghasum is not expected to be + // initialized but is. + ErrInitialized = errors.New("ghasum is already initialized") + + // ErrNotInitialized is the error used when ghasum is expected to be + // initialized but is not. + ErrNotInitialized = errors.New("ghasum has not yet been initialized") + + // ErrNotInitialized is the error used when the ghasum checksum file could not + // be created. + ErrSumfileCreate = errors.New("could not create a checksum file") + + // ErrNotInitialized is the error used when the ghasum checksum file could not + // be encoded. + ErrSumfileEncode = errors.New("could not encode the checksum file") + + // ErrNotInitialized is the error used when the ghasum checksum file could not + // be opened. + ErrSumfileOpen = errors.New("could not open the checksum file") + + // ErrNotInitialized is the error used when a ghasum checksum file could not + // be decoded. + ErrSumfileDecode = errors.New("could not decode the checksum file") + + // ErrNotInitialized is the error used when the ghasum checksum file could not + // be read. + ErrSumfileRead = errors.New("could not read from the checksum file") + + // ErrNotInitialized is the error used when the ghasum checksum file could not + // be removed. + ErrSumfileRemove = errors.New("could not remove the checksum file") + + // ErrNotInitialized is the error used when the ghasum checksum file could not + // be unlocked after usage. + ErrSumfileUnlock = errors.New("could not unlock the checksum file") + + // ErrNotInitialized is the error used when the ghasum checksum file could not + // be written to. + ErrSumfileWrite = errors.New("could not write to the checksum file") +) diff --git a/internal/ghasum/operations.go b/internal/ghasum/operations.go new file mode 100644 index 0000000..2f153e4 --- /dev/null +++ b/internal/ghasum/operations.go @@ -0,0 +1,155 @@ +// Copyright 2024 Eric Cornelissen +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package ghasum + +import ( + "errors" + "io" + "io/fs" + + "github.com/ericcornelissen/ghasum/internal/cache" + "github.com/ericcornelissen/ghasum/internal/checksum" + "github.com/ericcornelissen/ghasum/internal/sumfile" +) + +type ( + // Config is the configuration for a ghasum operation. + Config struct { + // Repo is a pointer to the file system hierarchy of the target repository + // for the operation. + Repo fs.FS + + // Path is the absolute or relate path to the target repository for the + // operation. + // + // This must be provided in addition to Repo because that does not allow for + // non-read file system operation. + Path string + + // Cache is the cache that should be used for the operation. + Cache cache.Cache + } + + // Problem represents an issue detected when verifying ghasum checksums. + Problem string +) + +// Initialize will initialize ghasum for the repository specified in the given +// configuration. +func Initialize(cfg *Config) error { + file, err := create(cfg.Path) + if err != nil { + return err + } + + defer func() { + deinitialize := (err != nil) + if err = file.Close(); err != nil || deinitialize { + _ = remove(cfg.Path) + } + }() + + checksums, err := compute(cfg, checksum.BestAlgo) + if err != nil { + return err + } + + content, err := encode(sumfile.VersionLatest, checksums) + if err != nil { + return err + } + + if err := write(file, content); err != nil { + return err + } + + if err := unlock(cfg.Path); err != nil { + return err + } + + return nil +} + +// Update will update the ghasum checksums for the repository specified in the +// given configuration. +func Update(cfg *Config) error { + file, err := open(cfg.Path) + if err != nil { + return err + } + + defer func() { + _ = file.Close() + }() + + raw, err := io.ReadAll(file) + if err != nil { + return errors.Join(ErrSumfileRead, err) + } + + version, err := version(raw) + if err != nil { + return err + } + + checksums, err := compute(cfg, checksum.BestAlgo) + if err != nil { + return err + } + + encoded, err := encode(version, checksums) + if err != nil { + return err + } + + if err := clear(file); err != nil { + return err + } + + if err := write(file, encoded); err != nil { + return err + } + + if err := unlock(cfg.Path); err != nil { + return err + } + + return nil +} + +// Verify will compare the stored ghasum checksums against recomputed checksums +// for the repository specified in the given configuration. +// +// Verification report checksums that do not match and checksums that are +// missing. It does not report checksums that are not used. +func Verify(cfg *Config) ([]Problem, error) { + raw, err := read(cfg.Repo) + if err != nil { + return nil, err + } + + stored, err := decode(raw) + if err != nil { + return nil, err + } + + fresh, err := compute(cfg, checksum.Sha256) + if err != nil { + return nil, err + } + + result := compare(fresh, stored) + return result, nil +} diff --git a/internal/github/clone.go b/internal/github/clone.go new file mode 100644 index 0000000..cf88bd5 --- /dev/null +++ b/internal/github/clone.go @@ -0,0 +1,126 @@ +// Copyright 2024 Eric Cornelissen +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package github + +import ( + "fmt" + "os" + "path" + + "github.com/go-git/go-git/v5" + "github.com/go-git/go-git/v5/plumbing" +) + +// A Repository represents a GitHub repository. +type Repository struct { + // Owner is the name of the user or organization that owns the project. + Owner string + + // Project is the name of the project to clone. + Project string + + // Ref is the reference to check out. + Ref string +} + +// Clone will clone the given repository at the exact ref from GitHub into the +// given directory. Note that the git index will be omitted. +func Clone(dir string, repo *Repository) error { + if err := clone(dir, repo); err != nil { + return err + } + + if err := os.RemoveAll(path.Join(dir, ".git")); err != nil { + return fmt.Errorf("could not remove git index: %v", err) + } + + return nil +} + +func clone(dir string, repo *Repository) error { + if err := cloneAtTag(dir, repo); err == nil { + return nil + } + + if err := cloneAtBranch(dir, repo); err == nil { + return nil + } + + return cloneAtCommit(dir, repo) +} + +func cloneAtBranch(dir string, repo *Repository) error { + opts := git.CloneOptions{ + URL: toUrl(repo), + Depth: 1, + SingleBranch: true, + Tags: git.NoTags, + ReferenceName: plumbing.NewBranchReferenceName(repo.Ref), + } + + _, err := git.PlainClone(dir, false, &opts) + if err != nil { + return fmt.Errorf("could not clone %q (as branch) from %q: %v", repo.Ref, opts.URL, err) + } + + return nil +} + +func cloneAtCommit(dir string, repo *Repository) error { + cloneOpts := git.CloneOptions{ + URL: toUrl(repo), + Tags: git.NoTags, + } + + repository, err := git.PlainClone(dir, false, &cloneOpts) + if err != nil { + return fmt.Errorf("could not clone from %q: %v", cloneOpts.URL, err) + } + + worktree, err := repository.Worktree() + if err != nil { + return fmt.Errorf("could not obtain worktree for %s/%s: %v", repo.Owner, repo.Project, err) + } + + checkoutOpts := git.CheckoutOptions{ + Hash: plumbing.NewHash(repo.Ref), + } + if err = worktree.Checkout(&checkoutOpts); err == nil { + return nil + } + + return fmt.Errorf("could not checkout ref %q for %s/%s: %v", repo.Ref, repo.Owner, repo.Project, err) +} + +func cloneAtTag(dir string, repo *Repository) error { + opts := git.CloneOptions{ + URL: toUrl(repo), + Depth: 1, + SingleBranch: true, + Tags: git.NoTags, + ReferenceName: plumbing.NewTagReferenceName(repo.Ref), + } + + _, err := git.PlainClone(dir, false, &opts) + if err != nil { + return fmt.Errorf("could not clone %q (as tag) from %q: %v", repo.Ref, opts.URL, err) + } + + return nil +} + +func toUrl(repo *Repository) (url string) { + return fmt.Sprintf("https://github.com/%s/%s", repo.Owner, repo.Project) +} diff --git a/internal/github/clone_test.go b/internal/github/clone_test.go new file mode 100644 index 0000000..dcf739f --- /dev/null +++ b/internal/github/clone_test.go @@ -0,0 +1,91 @@ +// Copyright 2024 Eric Cornelissen +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package github + +import ( + "strings" + "testing" + "testing/quick" +) + +func TestToUrl(t *testing.T) { + t.Parallel() + + t.Run("Valid examples", func(t *testing.T) { + t.Parallel() + + type TestCase struct { + in Repository + want string + } + + testCases := []TestCase{ + { + in: Repository{ + Owner: "foo", + Project: "bar", + }, + want: "https://github.com/foo/bar", + }, + { + in: Repository{ + Owner: "ericcornelissen", + Project: "ghasum", + }, + want: "https://github.com/ericcornelissen/ghasum", + }, + } + + for _, tc := range testCases { + t.Run(tc.want, func(t *testing.T) { + got := toUrl(&tc.in) + if want := tc.want; got != want { + t.Errorf("Incorrect result (got %q, wan %q)", got, want) + } + }) + } + }) + + t.Run("Arbitrary", func(t *testing.T) { + t.Parallel() + + isGitHubUrl := func(repo Repository) bool { + url := toUrl(&repo) + return strings.HasPrefix(url, "https://github.com/") + } + + if err := quick.Check(isGitHubUrl, nil); err != nil { + t.Errorf("Missing GitHub URL for: %v", err) + } + + containsOwner := func(repo Repository) bool { + url := toUrl(&repo) + return strings.Contains(url, repo.Owner) + } + + if err := quick.Check(containsOwner, nil); err != nil { + t.Errorf("Missing repository owner for: %v", err) + } + + containsProject := func(repo Repository) bool { + url := toUrl(&repo) + return strings.Contains(url, repo.Project) + } + + if err := quick.Check(containsProject, nil); err != nil { + t.Errorf("Missing repository project for: %v", err) + } + }) +} diff --git a/internal/github/doc.go b/internal/github/doc.go new file mode 100644 index 0000000..4206016 --- /dev/null +++ b/internal/github/doc.go @@ -0,0 +1,17 @@ +// Copyright 2024 Eric Cornelissen +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// Package github provides functionality for interacting with GitHub +// repositories. +package github diff --git a/internal/sumfile/doc.go b/internal/sumfile/doc.go new file mode 100644 index 0000000..a7b3f20 --- /dev/null +++ b/internal/sumfile/doc.go @@ -0,0 +1,18 @@ +// Copyright 2024 Eric Cornelissen +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// Package sumfile provides functionality for encoding and decoding checksum +// files. The package is not concerned with creating or comparing checksums, nor +// managing checksum files. +package sumfile diff --git a/internal/sumfile/errors.go b/internal/sumfile/errors.go new file mode 100644 index 0000000..000314e --- /dev/null +++ b/internal/sumfile/errors.go @@ -0,0 +1,31 @@ +// Copyright 2024 Eric Cornelissen +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package sumfile + +import "errors" + +var ( + // ErrCorrupted is the error when a checksum file is corrupted. + ErrCorrupted = errors.New("checksums are corrupted") + + // ErrHeaders is the error for when checksum headers are invalid. + ErrHeaders = errors.New("checksum headers are invalid") + + // ErrInvalid is the error for when checksums are invalid. + ErrInvalid = errors.New("checksums are invalid") + + // ErrSyntax is the error when a checksum file has a syntax error. + ErrSyntax = errors.New("syntax error") +) diff --git a/internal/sumfile/shared_test.go b/internal/sumfile/shared_test.go new file mode 100644 index 0000000..ad04b71 --- /dev/null +++ b/internal/sumfile/shared_test.go @@ -0,0 +1,108 @@ +// Copyright 2024 Eric Cornelissen +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package sumfile + +import ( + "reflect" + "testing" +) + +func SetEqual(got, want []Entry) bool { +OUTER_GOT: + for _, got := range got { + for _, want := range want { + if reflect.DeepEqual(got, want) { + continue OUTER_GOT + } + } + + return false + } + +OUTER_WANT: + for _, want := range want { + for _, got := range got { + if reflect.DeepEqual(got, want) { + continue OUTER_WANT + } + } + + return false + } + + return true +} + +func TestSetEqual(t *testing.T) { + t.Parallel() + + type TestCase struct { + name string + a []Entry + b []Entry + want bool + } + + testCases := []TestCase{ + { + name: "identical", + a: []Entry{ + { + Checksum: "bar", + ID: []string{"foo"}, + }, + }, + b: []Entry{ + { + Checksum: "bar", + ID: []string{"foo"}, + }, + }, + want: true, + }, + { + name: "in a but not in b", + a: []Entry{ + { + Checksum: "bar", + ID: []string{"foo"}, + }, + }, + b: []Entry{}, + want: false, + }, + { + name: "in b but not in a", + a: []Entry{}, + b: []Entry{ + { + Checksum: "bar", + ID: []string{"foo"}, + }, + }, + want: false, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + if got, want := SetEqual(tc.a, tc.b), tc.want; got != want { + t.Errorf("Wrong result (got %t, want %t)", got, want) + } + }) + } +} diff --git a/internal/sumfile/sumfile.go b/internal/sumfile/sumfile.go new file mode 100644 index 0000000..f2b82f9 --- /dev/null +++ b/internal/sumfile/sumfile.go @@ -0,0 +1,144 @@ +// Copyright 2024 Eric Cornelissen +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package sumfile + +import ( + "errors" + "fmt" + "strconv" + "strings" +) + +// An Entry represents a single checksum entry in a checksum file. +type Entry struct { + // Checksum is the checksum value for the entry. + Checksum string + + // ID is the identifier for the entry. Can have any number of parts but must + // not be empty. + ID []string +} + +// Decode parses the given checksum file content into Entries. This will error +// if there is a syntax error in the checksum file or if the checksum file is +// otherwise corrupted (for example multiple checksum directives for one Entry). +func Decode(stored string) ([]Entry, error) { + lines := strings.Split(stored, "\n") + headers, err := parseHeaders(lines) + if err != nil { + return nil, err + } + + version, err := extractVersion(headers) + if err != nil { + return nil, err + } + + if lines[len(lines)-1] != "" { + err = errors.New("missing final newline") + return nil, errors.Join(ErrSyntax, err) + } + + content := []string{} + if len(lines) > len(headers)+1 { + content = lines[len(headers)+1 : len(lines)-1] + } + + var checksums []Entry + switch version { + case Version1: + checksums, err = decodeV1(content) + default: + err = unknownVersion(version) + } + + if err != nil { + return nil, err + } + + return checksums, nil +} + +// DecodeVersion parses the given checksum file content to extract the version. +// +// This function may succeed even if the checksum file is corrupted. +func DecodeVersion(stored string) (Version, error) { + lines := strings.Split(stored, "\n") + headers, err := parseHeaders(lines) + if err != nil { + return 0, err + } + + return extractVersion(headers) +} + +// Encode encodes the given checksums according to the specification of the +// given version. +func Encode(version Version, checksums []Entry) (string, error) { + var ( + encoded string + err error + ) + + switch version { + case Version1: + encoded, err = encodeV1(checksums) + default: + err = unknownVersion(version) + } + + return fmt.Sprintf("version %d\n\n%s", version, encoded), err +} + +func parseHeaders(lines []string) (map[string]string, error) { + headers := make(map[string]string, 0) + for i, line := range lines { + if len(line) == 0 { + break + } + + j := strings.IndexRune(line, ' ') + if j == -1 { + err := fmt.Errorf("invalid header on line %d", i) + return nil, errors.Join(ErrSyntax, err) + } + + key := line[0:j] + value := line[j+1:] + headers[key] = value + } + + return headers, nil +} + +func extractVersion(headers map[string]string) (Version, error) { + version, ok := headers["version"] + if !ok { + err := errors.New("version not found") + return 0, errors.Join(ErrSyntax, err) + } + + rawVersion, err := strconv.Atoi(version) + if err != nil { + err := errors.New("version not a number") + return 0, errors.Join(ErrSyntax, err) + } + + return Version(rawVersion), nil +} + +func unknownVersion(version Version) error { + return fmt.Errorf("unknown version %d", version) +} diff --git a/internal/sumfile/sumfile_test.go b/internal/sumfile/sumfile_test.go new file mode 100644 index 0000000..d3f9b7b --- /dev/null +++ b/internal/sumfile/sumfile_test.go @@ -0,0 +1,120 @@ +// Copyright 2024 Eric Cornelissen +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package sumfile + +import ( + "testing" + "testing/quick" +) + +func TestAnyVersion(t *testing.T) { + t.Parallel() + + decodable := func(version Version, entries []Entry) bool { + version = (version % VersionLatest) + 1 // normalize version + + encoded, err := Encode(version, entries) + if err != nil { + return true + } + + decoded, err := Decode(encoded) + if err != nil { + return false + } + + return SetEqual(decoded, entries) + } + + if err := quick.Check(decodable, nil); err != nil { + t.Errorf("decode(encode(x)) errored for: %v", err) + } +} + +func TestNoChecksums(t *testing.T) { + t.Parallel() + + t.Run("Decode", func(t *testing.T) { + t.Parallel() + + entries, err := Decode("version 1\n") + if err != nil { + t.Fatalf("Unexpected error: %+v", err) + } + + if got, want := len(entries), 0; got != want { + t.Errorf("Incorrect result count (got %d, want %d)", got, want) + } + }) + + t.Run("Encode", func(t *testing.T) { + t.Parallel() + + if _, err := Encode(1, []Entry{}); err != nil { + t.Fatalf("Unexpected error: %+v", err) + } + }) +} + +func TestUnknownVersion(t *testing.T) { + t.Parallel() + + t.Run("Decode", func(t *testing.T) { + t.Parallel() + + if _, err := Decode("version 0\n"); err == nil { + t.Fatal("Unexpected success") + } + }) + + t.Run("Encode", func(t *testing.T) { + t.Parallel() + + if _, err := Encode(0, []Entry{}); err == nil { + t.Fatal("Unexpected success") + } + }) +} + +func TestDecodeCorruptFile(t *testing.T) { + t.Parallel() + + testCases := []string{ + "", + " ", + "version", + "version ", + "not a version", + "version 1", + `version 1 + +duplicate checksum +duplicate checksum +`, + `version 1 + +missing final newline`, + } + + for _, tc := range testCases { + t.Run(tc, func(t *testing.T) { + t.Parallel() + + if _, err := Decode(tc); err == nil { + t.Fatal("Unexpected success") + } + }) + } +} diff --git a/internal/sumfile/validate.go b/internal/sumfile/validate.go new file mode 100644 index 0000000..6e93736 --- /dev/null +++ b/internal/sumfile/validate.go @@ -0,0 +1,49 @@ +// Copyright 2024 Eric Cornelissen +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package sumfile + +func hasMissing(entries []Entry) bool { + for _, entry := range entries { + if len(entry.Checksum) == 0 || len(entry.ID) == 0 { + return true + } + + for _, part := range entry.ID { + if len(part) == 0 { + return true + } + } + } + + return false +} + +func hasDuplicates(entries []Entry) bool { + seen := make(map[string]any, 0) + for _, entry := range entries { + key := "" + for _, part := range entry.ID { + key += part + } + + if _, ok := seen[key]; ok { + return true + } + + seen[key] = struct{}{} + } + + return false +} diff --git a/internal/sumfile/validate_test.go b/internal/sumfile/validate_test.go new file mode 100644 index 0000000..4dc81a0 --- /dev/null +++ b/internal/sumfile/validate_test.go @@ -0,0 +1,238 @@ +// Copyright 2024 Eric Cornelissen +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package sumfile + +import ( + "testing" +) + +func TestHasEmpty(t *testing.T) { + t.Parallel() + + t.Run("Non-empty examples", func(t *testing.T) { + t.Parallel() + + type TestCase struct { + name string + entries []Entry + } + + testCases := []TestCase{ + { + name: "no entries", + entries: []Entry{}, + }, + { + name: "one ID parts", + entries: []Entry{ + { + Checksum: "checksum", + ID: []string{"foobar"}, + }, + }, + }, + { + name: "multiple ID parts", + entries: []Entry{ + { + Checksum: "checksum", + ID: []string{"foo", "bar"}, + }, + }, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + got := hasMissing(tc.entries) + if got { + t.Fatal("Unexpected positive result") + } + }) + } + }) + + t.Run("Empty examples", func(t *testing.T) { + t.Parallel() + + type TestCase struct { + name string + entries []Entry + } + + testCases := []TestCase{ + { + name: "empty checksum", + entries: []Entry{ + { + Checksum: "", + ID: []string{"foobar"}, + }, + }, + }, + { + name: "empty id array", + entries: []Entry{ + { + Checksum: "not-empty", + ID: []string{}, + }, + }, + }, + { + name: "empty id part", + entries: []Entry{ + { + Checksum: "not-empty", + ID: []string{""}, + }, + }, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + got := hasMissing(tc.entries) + if !got { + t.Fatal("Unexpected negative result") + } + }) + } + }) +} + +func TestHasDuplicates(t *testing.T) { + t.Parallel() + + t.Run("No duplicates examples", func(t *testing.T) { + t.Parallel() + + type TestCase struct { + name string + entries []Entry + } + + testCases := []TestCase{ + { + name: "no entries", + entries: []Entry{}, + }, + { + name: "one part", + entries: []Entry{ + { + ID: []string{"foo"}, + }, + { + ID: []string{"bar"}, + }, + }, + }, + { + name: "two parts", + entries: []Entry{ + { + ID: []string{"foo", "bar"}, + }, + { + ID: []string{"hello", "world"}, + }, + }, + }, + { + name: "two parts, first differs", + entries: []Entry{ + { + ID: []string{"bar", "foo"}, + }, + { + ID: []string{"baz", "foo"}, + }, + }, + }, + { + name: "two parts, second differs", + entries: []Entry{ + { + ID: []string{"foo", "bar"}, + }, + { + ID: []string{"foo", "baz"}, + }, + }, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + got := hasDuplicates(tc.entries) + if got { + t.Fatal("Unexpected positive result") + } + }) + } + }) + + t.Run("Duplicate examples", func(t *testing.T) { + t.Parallel() + + type TestCase struct { + name string + entries []Entry + } + + testCases := []TestCase{ + { + name: "one part", + entries: []Entry{ + { + ID: []string{"foobar"}, + }, + { + ID: []string{"foobar"}, + }, + }, + }, + { + name: "multiple parts", + entries: []Entry{ + { + ID: []string{"foo", "bar"}, + }, + { + ID: []string{"foo", "bar"}, + }, + }, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + got := hasDuplicates(tc.entries) + if !got { + t.Fatal("Unexpected negative result") + } + }) + } + }) +} diff --git a/internal/sumfile/version1.go b/internal/sumfile/version1.go new file mode 100644 index 0000000..5a27cf8 --- /dev/null +++ b/internal/sumfile/version1.go @@ -0,0 +1,90 @@ +// Copyright 2024 Eric Cornelissen +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package sumfile + +import ( + "errors" + "fmt" + "sort" + "strings" +) + +func decodeV1(lines []string) ([]Entry, error) { + entries := make([]Entry, len(lines)) + for i, line := range lines { + // split "line" into "id[@id..]" "sum" + j := strings.IndexRune(line, ' ') + if j <= 0 || j >= len(line)-1 { + err := fmt.Errorf("syntax error on line %d", i+2) + return nil, errors.Join(ErrCorrupted, err) + } + + entries[i] = Entry{ + ID: strings.Split(line[:j], "@"), + Checksum: line[j+1:], + } + } + + if !validV1(entries) { + return nil, ErrInvalid + } + + return entries, nil +} + +func encodeV1(entries []Entry) (string, error) { + if !validV1(entries) { + return "", ErrInvalid + } + + var sb strings.Builder + lines := make([]string, len(entries)) + for i, entry := range entries { + for i, part := range entry.ID { + if i != 0 { + sb.WriteRune('@') + } + sb.WriteString(part) + } + + sb.WriteRune(' ') + sb.WriteString(entry.Checksum) + sb.WriteRune('\n') + + lines[i] = sb.String() + sb.Reset() + } + + sort.Strings(lines) + return strings.Join(lines, ""), nil +} + +func validV1(entries []Entry) bool { + if hasDuplicates(entries) || hasMissing(entries) { + return false + } + + for _, entry := range entries { + if strings.ContainsAny(entry.Checksum, "\n ") { + return false + } + + if strings.ContainsAny(strings.Join(entry.ID, ""), "\n @") { + return false + } + } + + return true +} diff --git a/internal/sumfile/version1_test.go b/internal/sumfile/version1_test.go new file mode 100644 index 0000000..8d8c12c --- /dev/null +++ b/internal/sumfile/version1_test.go @@ -0,0 +1,340 @@ +// Copyright 2024 Eric Cornelissen +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package sumfile + +import ( + "errors" + "fmt" + "slices" + "strings" + "testing" + "testing/quick" +) + +func TestVersion1(t *testing.T) { + t.Parallel() + + correct := func(entries []Entry) bool { + if !validV1(entries) { + return true + } + + encoded, _ := encodeV1(entries) + lines := strings.Split(encoded, "\n") + + decoded, err := decodeV1(lines[:len(lines)-1]) + if err != nil { + return true // Ignore errors, tested separately + } + + return SetEqual(decoded, entries) + } + + if err := quick.Check(correct, nil); err != nil { + t.Errorf("decode(encode(x)) != x for: %v", err) + } + + decodable := func(entries []Entry) bool { + if !validV1(entries) { + return true + } + + encoded, _ := encodeV1(entries) + lines := strings.Split(encoded, "\n") + + _, err := decodeV1(lines[:len(lines)-1]) + return err == nil + } + + if err := quick.Check(decodable, nil); err != nil { + t.Errorf("decode(encode(x)) errored for: %v", err) + } + + deterministic := func(entries []Entry) bool { + got1, err1 := encodeV1(entries) + got2, err2 := encodeV1(entries) + return got1 == got2 && errors.Is(err1, err2) + } + + if err := quick.Check(deterministic, nil); err != nil { + t.Errorf("encode(x) != encode(x) for: %v", err) + } +} + +func TestDecodeV1(t *testing.T) { + t.Run("Valid examples", func(t *testing.T) { + t.Parallel() + + type TestCase struct { + name string + content []string + want []Entry + } + + testCases := []TestCase{ + { + name: "no checksums", + content: []string{}, + want: []Entry{}, + }, + { + name: "one checksum", + content: []string{ + "foo bar", + }, + want: []Entry{ + { + Checksum: "bar", + ID: []string{"foo"}, + }, + }, + }, + { + name: "one multi-part ID checksum", + content: []string{ + "foo@bar foobar", + }, + want: []Entry{ + { + Checksum: "foobar", + ID: []string{"foo", "bar"}, + }, + }, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + got, err := decodeV1(tc.content) + if err != nil { + t.Fatalf("Unexpected error: %+v", err) + } + + if got, want := len(got), len(tc.want); got != want { + t.Fatalf("Incorrect result length (got %d, want %d)", got, want) + } + + for i, got := range got { + want := tc.want[i] + + if got, want := got.Checksum, want.Checksum; got != want { + t.Fatalf("Incorrect checksum %d (got %q, want %q)", i, got, want) + } + + if got, want := got.ID, want.ID; !slices.Equal(got, want) { + t.Fatalf("Incorrect id %d (got %v, want %v)", i, got, want) + } + } + }) + } + }) + + t.Run("Invalid examples", func(t *testing.T) { + t.Parallel() + + type TestCase struct { + name string + content []string + want int + } + + testCases := []TestCase{ + { + name: "no id-checksum separator", + content: []string{ + "foobar", + }, + want: 3, + }, + { + name: "no checksum", + content: []string{ + "foobar ", + }, + want: 3, + }, + { + name: "no id", + content: []string{ + " foobar", + }, + want: 3, + }, + { + name: "on a later line", + content: []string{ + "foo bar", + "syntax-error", + }, + want: 4, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + _, err := decodeV1(tc.content) + if err == nil { + t.Fatal("Unexpected success") + } + + if got, want := err.Error(), fmt.Sprintf("line %d", tc.want); strings.Contains(got, want) { + t.Errorf("Incorrect line number (got %q, want %q)", got, want) + } + }) + } + }) +} + +func TestEncodeV1(t *testing.T) { + t.Run("Valid examples", func(t *testing.T) { + t.Parallel() + + type TestCase struct { + name string + content []Entry + want string + } + + testCases := []TestCase{ + { + name: "no checksums", + content: []Entry{}, + want: ``, + }, + { + name: "one checksum", + content: []Entry{ + { + Checksum: "bar", + ID: []string{"foo"}, + }, + }, + want: `foo bar +`, + }, + { + name: "one multi-part ID checksum", + content: []Entry{ + { + Checksum: "foobar", + ID: []string{"foo", "bar"}, + }, + }, + want: `foo@bar foobar +`, + }, + { + name: "order", + content: []Entry{ + { + Checksum: "bb", + ID: []string{"b"}, + }, + { + Checksum: "aa", + ID: []string{"a"}, + }, + }, + want: `a aa +b bb +`, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + got, err := encodeV1(tc.content) + if err != nil { + t.Fatalf("Unexpected error: %+v", err) + } + + if want := tc.want; got != want { + t.Fatalf("Incorrect result (got %q, want %q)", got, want) + } + }) + } + }) + + t.Run("Invalid examples", func(t *testing.T) { + t.Parallel() + + type TestCase struct { + name string + content []Entry + } + + testCases := []TestCase{ + { + name: "checksum with newline", + content: []Entry{ + { + ID: []string{"anything"}, + Checksum: "Hello\nworld!", + }, + }, + }, + { + name: "checksum with space", + content: []Entry{ + { + ID: []string{"anything"}, + Checksum: "Hello world!", + }, + }, + }, + { + name: "ID part with newline", + content: []Entry{ + { + ID: []string{"Hello\nworld!"}, + Checksum: "anything", + }, + }, + }, + { + name: "ID part with space", + content: []Entry{ + { + ID: []string{"Hello world!"}, + Checksum: "anything", + }, + }, + }, + { + name: "ID part with '@'", + content: []Entry{ + { + ID: []string{"foo@bar"}, + Checksum: "anything", + }, + }, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + if _, err := encodeV1(tc.content); err == nil { + t.Fatal("Unexpected success") + } + }) + } + }) +} diff --git a/internal/sumfile/versions.go b/internal/sumfile/versions.go new file mode 100644 index 0000000..8a2c06c --- /dev/null +++ b/internal/sumfile/versions.go @@ -0,0 +1,26 @@ +// Copyright 2024 Eric Cornelissen +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package sumfile + +// Version is the type for checksum file versions. +type Version uint + +const ( + // Version1 is the first checksum file version. + Version1 Version = 1 + iota + + // VersionLatest has the value of the latest checksum file Version. + VersionLatest = Version1 +) diff --git a/main.go b/main.go deleted file mode 100644 index f24bd15..0000000 --- a/main.go +++ /dev/null @@ -1,304 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 - -package main - -import ( - "flag" - "fmt" - "os" - "path" - "slices" - "sort" - "strings" - - "github.com/go-git/go-git/v5" - "github.com/go-git/go-git/v5/plumbing" - "golang.org/x/mod/sumdb/dirhash" - "gopkg.in/yaml.v3" -) - -const ( - exitCodeSuccess = 0 - exitCodeError = 1 - exitCodeUsage = 2 - exitCodeFailed = 3 -) - -var ( - flagDebug = flag.Bool( - "debug", - false, - "Enable debugging mode", - ) - flagRepo = flag.String( - "repo", - "", - "The repository to work on", - ) - flagValidate = flag.Bool( - "validate", - false, - "Validate recorded checksums", - ) -) - -func main() { - os.Exit(run()) -} - -func run() int { - flag.Parse() - - if *flagRepo == "" { - fmt.Println("Provide a GitHub repository name using '-repo'") - return exitCodeUsage - } - - // --------------------------------------------------------------------------- - - fmt.Println("[INFO ] Creating temporary working directory") - wd, err := os.MkdirTemp(os.TempDir(), "ghasum-*") - if err != nil { - fmt.Printf("[ERROR] couldn't create temporary directory: %s", err) - return exitCodeError - } - - if *flagDebug { - fmt.Printf("[DEBUG] temporary working directory is: '%s'\n", wd) - } else { - defer os.RemoveAll(wd) - } - - // --------------------------------------------------------------------------- - - actions, err := getGhaDependencies(wd, *flagRepo) - if err != nil { - fmt.Printf("[ERROR] couldn't obtain GHA dependencies: %s\n", err) - return exitCodeError - } - - // --------------------------------------------------------------------------- - - sums := make([][2]string, len(actions)) - for i, action := range actions { - sum, err := getRepositoryHash(wd, action) - if err != nil { - fmt.Printf("[ERROR] %s\n", err) - return exitCodeError - } - - sums[i] = [2]string{action, sum} - } - - // --------------------------------------------------------------------------- - - if *flagValidate { - fmt.Println("[INFO ] Validating obtained sums against gha.sum") - ok, err := validateSums(sums) - if err != nil { - fmt.Printf("[ERROR] %s\n", err) - return exitCodeError - } - - if !ok { - return exitCodeFailed - } else { - fmt.Println("Validation successful") - } - } else { - fmt.Println("[INFO ] Storing obtained sums in gha.sum") - if err := storeSums(sums); err != nil { - fmt.Printf("[ERROR] %s\n", err) - return exitCodeError - } - } - - return exitCodeSuccess -} - -// ----------------------------------------------------------------------------- - -func validateSums(sums [][2]string) (ok bool, err error) { - rawStored, err := os.ReadFile("gha.sum") - if err != nil { - return ok, fmt.Errorf("couldn't read gha.sum file: %v", err) - } - - lines := strings.Split(string(rawStored), "\n") - lines = lines[:len(lines)-1] - if len(lines) != len(sums) { - return false, nil - } - - for i, line := range lines { - parts := strings.Split(line, " ") - if len(parts) != 2 { - return ok, fmt.Errorf("invalid gha.sum") - } - expectedDep, expectedSum := parts[0], parts[1] - - actualDep, actualSum := sums[i][0], sums[i][1] - - if actualDep != expectedDep || actualSum != expectedSum { - return false, nil - } - } - - return true, nil -} - -func storeSums(sums [][2]string) (err error) { - var sb strings.Builder - for _, sum := range sums { - name, hash := sum[0], sum[1] - sb.WriteString(name) - sb.WriteRune(' ') - sb.WriteString(hash) - sb.WriteRune('\n') - } - - f, err := os.Create("gha.sum") - if err != nil { - return fmt.Errorf("couldn't create gha.sum file: %v", err) - } - - if _, err := f.WriteString(sb.String()); err != nil { - return fmt.Errorf("couldn't write to gha.sum file: %v", err) - } - - return nil -} - -// ----------------------------------------------------------------------------- - -func getGhaDependencies(wd string, repoName string) (actions []string, err error) { - url := repoNameToGitHubUrl(repoName) - - wd = path.Join(wd, ".source") - - fmt.Printf("[INFO ] Cloning source repository (url: '%s')\n", url) - _, err = git.PlainClone(wd, false, &git.CloneOptions{ - URL: url, - Progress: os.Stdout, - }) - if err != nil { - return actions, fmt.Errorf("couldn't clone the repository: %v", err) - } - - wd = path.Join(wd, ".github", "workflows") - dirEntries, err := os.ReadDir(wd) - if err != nil { - return actions, fmt.Errorf("couldn't read workflows directory: %v", err) - } - - for _, entry := range dirEntries { - if entry.IsDir() { - continue - } - - if ext := path.Ext(entry.Name()); ext != ".yml" && ext != ".yaml" { - continue - } - - workflowPath := path.Join(wd, entry.Name()) - - workflowRaw, err := os.ReadFile(workflowPath) - if err != nil { - return actions, fmt.Errorf("couldn't read workflow '%s': %v", workflowPath, err) - } - - workflow, err := parseGhaWorkflow(workflowRaw) - if err != nil { - return actions, fmt.Errorf("couldn't parse workflow '%s': %v", workflowPath, err) - } - - for _, job := range workflow.Jobs { - for _, step := range job.Steps { - if step.Uses != "" { - if !slices.Contains(actions, step.Uses) { - actions = append(actions, step.Uses) - } - } - } - } - } - - sort.Strings(actions) - return actions, nil -} - -func parseGhaWorkflow(data []byte) (w workflow, err error) { - if err = yaml.Unmarshal(data, &w); err != nil { - return w, fmt.Errorf("couldn't parse workflow: %v", err) - } - - return w, nil -} - -type workflow struct { - Jobs map[string]job `yaml:"jobs"` -} - -type job struct { - Steps []step `yaml:"steps"` -} - -type step struct { - Uses string `yaml:"uses"` -} - -// ----------------------------------------------------------------------------- - -func getRepositoryHash(wd string, action string) (sum string, err error) { - if strings.HasPrefix(action, ".") { - return "n/a", nil - } - - parts := strings.Split(action, "@") - repo, ref := parts[0], parts[1] - repo = strings.Join(strings.Split(repo, "/")[0:2], "/") - url := repoNameToGitHubUrl(repo) - wd = path.Join(wd, action) - - fmt.Printf("[INFO ] Cloning dependency repository (url: '%s')\n", url) - r, err := git.PlainClone(wd, false, &git.CloneOptions{ - URL: url, - Progress: os.Stdout, - }) - if err != nil { - return sum, fmt.Errorf("couldn't clone the repository: %v", err) - } - - w, err := r.Worktree() - if err != nil { - return sum, fmt.Errorf("couldn't obtain worktree: %v", err) - } - - err = w.Checkout(&git.CheckoutOptions{ - Hash: plumbing.NewHash(ref), - }) - if err != nil { - return sum, fmt.Errorf("couldn't checkout specific ref (%s): %v", ref, err) - } - - // NOTE: Remove the .git index to ensure the directory hash is reproducible. - // The contents of this file my be different every clone. - if err := os.RemoveAll(path.Join(wd, ".git")); err != nil { - return sum, fmt.Errorf("couldn't remove git index: %v", err) - } - - // --------------------------------------------------------------------------- - - sum, err = dirhash.HashDir(wd, "", dirhash.DefaultHash) - if err != nil { - return sum, fmt.Errorf("couldn't compute hash: %v", err) - } - - return sum, nil -} - -// ----------------------------------------------------------------------------- - -func repoNameToGitHubUrl(name string) (url string) { - return fmt.Sprintf("https://github.com/%s", name) -} diff --git a/tasks.go b/tasks.go new file mode 100644 index 0000000..7245f58 --- /dev/null +++ b/tasks.go @@ -0,0 +1,583 @@ +// MIT No Attribution +// +// Copyright (c) 2024 Eric Cornelissen +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +//go:build tasks + +package main + +import ( + "bytes" + "errors" + "fmt" + "go/ast" + "go/parser" + "go/token" + "io" + "os" + "os/exec" + "regexp" + "strings" +) + +// Audit for known vulnerabilities. +func TaskAudit(t *T) error { + t.Log("Checking for vulnerabilities...") + return t.Exec(`go run golang.org/x/vuln/cmd/govulncheck ./...`) +} + +// Build the ghasum binary for the current platform. +func TaskBuild(t *T) error { + t.Log("Building...") + return t.Exec(`go build ./cmd/ghasum`) +} + +// Build the ghasum binary for all supported platforms. +func TaskBuildAll(t *T) error { + type Target struct { + os string + arch string + } + + var ( + arch386 = "386" + archAmd64 = "amd64" + archArm = "arm" + archArm64 = "arm64" + osLinux = "linux" + osMac = "darwin" + osWindows = "windows" + ) + + var targets = []Target{ + {os: osLinux, arch: arch386}, + {os: osLinux, arch: archAmd64}, + {os: osLinux, arch: archArm}, + {os: osLinux, arch: archArm64}, + {os: osMac, arch: archAmd64}, + {os: osMac, arch: archArm64}, + {os: osWindows, arch: arch386}, + {os: osWindows, arch: archAmd64}, + {os: osWindows, arch: archArm}, + {os: osWindows, arch: archArm64}, + } + + t.Log("Building (all platforms)...") + if err := os.Mkdir("./_compiled", 0o755); err != nil { + return err + } + + archives := make([]string, len(targets)) + for i, target := range targets { + fmt.Printf("Compiling for %s/%s...\n", target.os, target.arch) + + executable := "ghasum" + if target.os == osWindows { + executable = "ghasum.exe" + } + + archiveCmd := "tar -czf" + if target.os == osWindows { + archiveCmd = "zip -9q" + } + + archiveExt := "tar.gz" + if target.os == osWindows { + archiveExt = "zip" + } + + archiveFile := fmt.Sprintf("ghasum_%s_%s.%s", target.os, target.arch, archiveExt) + archives[i] = archiveFile + + var ( + compile = fmt.Sprintf( + "env GOOS=%s GOARCH=%s go build -o %s ./cmd/ghasum", + target.os, + target.arch, + executable, + ) + archive = fmt.Sprintf( + "%s _compiled/%s %s", + archiveCmd, + archiveFile, + executable, + ) + ) + + if err := t.Exec(compile, archive); err != nil { + return err + } + } + + t.Log("Computing checksums...") + t.Cd("_compiled") + out, err := t.ExecS(`shasum --algorithm 512 ` + strings.Join(archives, " ")) + if err != nil { + return err + } + + return os.WriteFile("./_compiled/checksums-sha512.txt", []byte(out), 0o664) +} + +// Reset the project to a clean state. +func TaskClean(t *T) error { + var items = []string{ + "_compiled/", + "cover.html", + "cover.out", + "ghasum", + "ghasum.exe", + } + + t.Log("Cleaning...") + return t.Exec("git clean -fx " + strings.Join(items, " ")) +} + +// Run all tests and generate a coverage report. +func TaskCoverage(t *T) error { + t.Log("Generating coverage report...") + return t.Exec( + "go test -coverprofile cover.out ./...", + "go tool cover -html cover.out -o cover.html", + ) +} + +// Run an ephemeral development environment container. +func TaskDevEnv(t *T) error { + wd, err := os.Getwd() + if err != nil { + return err + } + + var ( + engine = t.Env("CONTAINER_ENGINE", "docker") + build = fmt.Sprintf( + "%s build --file Containerfile.dev --tag ghasum-dev-img .", + engine, + ) + run = fmt.Sprintf( + "%s run -it --rm --workdir /ghasum --mount 'type=bind,source=%s,target=/ghasum' --name ghasum-dev-env ghasum-dev-img", + engine, + wd, + ) + ) + + return t.Exec(build, run) +} + +// Format the source code. +func TaskFormat(t *T) error { + t.Log("Formatting...") + return t.Exec( + "go fmt ./...", + "go mod tidy", + "go run github.com/tetafro/godot/cmd/godot -w .", + "go run golang.org/x/tools/cmd/goimports -w .", + ) +} + +// Check the source code formatting. +func TaskFormatCheck(t *T) error { + t.Log("Checking formatting...") + + out, err := t.ExecS( + "gofmt -l .", + "go run github.com/tetafro/godot/cmd/godot .", + "go run golang.org/x/tools/cmd/goimports -l .", + ) + if err != nil { + return err + } + + if out != "" { + return errors.New("not formatted") + } + + return nil +} + +// Check if the build is reproducible. +func TaskReproducible(t *T) error { + var ( + build = "go build ./cmd/ghasum" + checksum = "shasum --algorithm 512 ghasum" + ) + + t.Log("Initial build...") + checksum1, err := t.ExecS(build, checksum) + if err != nil { + return err + } + + t.Log("Reproducing build...") + checksum2, err := t.ExecS(build, checksum) + if err != nil { + return err + } + + if checksum1 != checksum2 { + return errors.New("Build did not reproduce") + } + + return nil +} + +// Run all tests. +func TaskTest(t *T) error { + t.Log("Testing...") + return t.Exec(`go test ./...`) +} + +// Run all tests in a random order. +func TaskTestRandomized(t *T) error { + t.Log("Testing (random order)...") + return t.Exec(`go test -shuffle=on ./...`) +} + +// Verify the project is in a good state. +func TaskVerify(t *T) error { + tasks := []Task{ + TaskBuild, + TaskFormatCheck, + TaskTest, + TaskVet, + } + + for _, task := range tasks { + if err := task(t); err != nil { + return err + } + } + + return nil +} + +// Vet the source code. +func TaskVet(t *T) error { + t.Log("Vetting...") + return t.Exec( + "go vet ./...", + "go run 4d63.com/gochecknoinits ./...", + "go run github.com/alexkohler/dogsled/cmd/dogsled -set_exit_status ./...", + "go run github.com/alexkohler/nakedret/v2/cmd/nakedret -l 0 ./...", + "go run github.com/alexkohler/prealloc -set_exit_status ./...", + "go run github.com/alexkohler/unimport ./...", + "go run github.com/butuzov/ireturn/cmd/ireturn ./...", + "go run github.com/catenacyber/perfsprint ./...", + "go run github.com/dkorunic/betteralign/cmd/betteralign ./...", + "go run github.com/go-critic/go-critic/cmd/gocritic check ./...", + "go run github.com/gordonklaus/ineffassign ./...", + "go run github.com/jgautheron/goconst/cmd/goconst -set-exit-status ./...", + "go run github.com/kisielk/errcheck ./...", + "go run github.com/kunwardeep/paralleltest -ignoreloopVar ./...", + "go run github.com/mdempsky/unconvert ./...", + "go run github.com/nishanths/exhaustive/cmd/exhaustive ./...", + "go run github.com/polyfloyd/go-errorlint -asserts ./...", + "go run github.com/tomarrell/wrapcheck/v2/cmd/wrapcheck ./...", + "go run github.com/ultraware/whitespace/cmd/whitespace ./...", + "go run gitlab.com/bosi/decorder/cmd/decorder -disable-dec-num-check ./...", + "go run go.uber.org/nilaway/cmd/nilaway -include-pkgs=github.com/ericcornelissen/ghasum ./...", + "go run golang.org/x/tools/go/analysis/passes/shadow/cmd/shadow ./...", + "go run honnef.co/go/tools/cmd/staticcheck ./...", + "go run mvdan.cc/unparam ./...", + ) +} + +// ------------------------------------------------------------------------------------------------- + +// T is a type passed to Task functions to perform common tasks. +type T struct { + dir string +} + +// Task is a function that performs a task. +type Task func(t *T) error + +// Cd changes the directory in which the task operates. +func (t *T) Cd(dir string) { + t.dir = dir +} + +// Env returns the value of the environment variable identified by key, or the fallback value. +func (t *T) Env(key, fallback string) string { + if value, present := os.LookupEnv(key); present { + return value + } else { + return fallback + } +} + +// Exec executes the commands printing to stdout. +func (t *T) Exec(commands ...string) error { + return t.ExecF(os.Stdout, commands...) +} + +// ExecF executes the commands writing stdout to buf. +func (t *T) ExecF(buf io.Writer, commands ...string) error { + for _, commandStr := range commands { + commandName, args := t.parseCommand(commandStr) + + cmd := exec.Command(commandName, args...) + cmd.Dir = t.dir + cmd.Stdin = os.Stdin + cmd.Stdout = buf + cmd.Stderr = os.Stderr + + if err := cmd.Run(); err != nil { + return err + } + } + + return nil +} + +// ExecS executes the commands returning stdout as a string. +func (t *T) ExecS(commands ...string) (string, error) { + buf := new(bytes.Buffer) + err := t.ExecF(buf, commands...) + return strings.TrimSpace(buf.String()), err +} + +// Log prints the messages as a line in bold. Useful to delineate steps in a task. +func (t *T) Log(msgs ...string) { + fmt.Print("\033[1m") + for _, msg := range msgs { + fmt.Print(msg) + } + fmt.Println("\033[0m") +} + +func (t *T) parseCommand(command string) (string, []string) { + commandExp := regexp.MustCompile(`'((?:\'|[^'])+?)'|"((?:\"|[^"])+?)"|(\S+)`) + matches := commandExp.FindAllStringSubmatch(command, -1) + parsed := make([]string, len(matches)) + for i, match := range matches { + if match[1] != "" { + parsed[i] = match[1] + } else if match[2] != "" { + parsed[i] = match[2] + } else { + parsed[i] = match[3] + } + } + + return parsed[0], parsed[1:] +} + +func main() { + type internalTask struct { + desc string + name string + } + + var ( + taskFnPrefix = "Task" + exprCapital = regexp.MustCompile(`(.)([A-Z])`) + exprHyphenated = regexp.MustCompile(`(^|-)[a-z]`) + ) + + var ( + typeCheckTaskParams = func(params []*ast.Field) bool { + if len(params) != 1 { + return false + } + + paramType, ok := params[0].Type.(*ast.StarExpr) + if !ok { + return false + } + + paramTypeIdent, ok := paramType.X.(*ast.Ident) + if !ok || paramTypeIdent.Name != "T" { + return false + } + + return true + } + typeCheckTaskResults = func(results []*ast.Field) bool { + if len(results) != 1 { + return false + } + + _, ok := results[0].Type.(ast.Expr) + return ok + } + ) + + var ( + parse = func() ([]internalTask, error) { + file, err := parser.ParseFile(token.NewFileSet(), "tasks.go", nil, parser.ParseComments) + if err != nil { + return nil, fmt.Errorf("could not parse file: %s", err) + } + + tasks := make([]internalTask, 0) + for _, decl := range file.Decls { + // Check the declaration type, only functions can be tasks + fn, ok := decl.(*ast.FuncDecl) + if !ok { + continue + } + + // Check for the task prefix, which marks a runnable task + fnName := fn.Name.Name + if !strings.HasPrefix(fnName, taskFnPrefix) { + continue + } + + // Check that the function signature is correct + if ok := typeCheckTaskParams(fn.Type.Params.List); !ok { + return nil, fmt.Errorf("wrong signature for %q, should accept '*T'", fnName) + } + if ok := typeCheckTaskResults(fn.Type.Results.List); !ok { + return nil, fmt.Errorf("wrong signature for %q, should return 'error'", fnName) + } + + // Convert the function name to a task name + name := strings.TrimPrefix(fnName, taskFnPrefix) + name = exprCapital.ReplaceAllString(name, "${1}-${2}") + name = strings.ToLower(name) + + // Extract task description as the first line of the doc comment + desc := fn.Doc.Text() + if eol := strings.IndexRune(desc, '\n'); eol != -1 { + desc = desc[0:eol] + } + + tasks = append(tasks, internalTask{desc, name}) + } + + return tasks, nil + } + build = func(tasks []string) ([]byte, error) { + wd, err := os.Getwd() + if err != nil { + return nil, errors.New("could not get the current working directory") + } + + original, err := os.ReadFile("./tasks.go") + if err != nil { + return nil, errors.New("could not read the task file") + } + + var sb strings.Builder + sb.WriteString(`func main() {var t T;`) + for _, taskName := range tasks { + name := exprHyphenated.ReplaceAllStringFunc(taskName, strings.ToUpper) + name = strings.ReplaceAll(name, "-", "") + + sb.WriteString(fmt.Sprintf(`t.Cd("%s");`, wd)) + sb.WriteString(fmt.Sprintf(`if err := Task%s(&t); err != nil {`, name)) + sb.WriteString(`fmt.Fprintln(os.Stderr);`) + sb.WriteString(`exitCode := 1;`) + sb.WriteString(`if exitErr, ok := err.(*exec.ExitError); ok {`) + sb.WriteString(`exitCode = exitErr.ExitCode()`) + sb.WriteString(`} else {`) + sb.WriteString(`fmt.Fprintf(os.Stderr, "Error: %v\n", err)`) + sb.WriteString(`};`) + sb.WriteString(fmt.Sprintf(`fmt.Fprintln(os.Stderr, "Task '%s' failed");`, taskName)) + sb.WriteString(`os.Exit(exitCode)`) + sb.WriteString(`};`) + } + sb.WriteRune('}') + + var ( + exprMain = regexp.MustCompile(`func main\(\) \{\n([^\n]*\n)+\}`) + exprUnusedImport = regexp.MustCompile(` "go/[a-z]*"\n`) + ) + + runner := exprMain.ReplaceAll(original, []byte(sb.String())) + runner = exprUnusedImport.ReplaceAll(runner, []byte{}) + return runner, nil + } + run = func(tasks []string) (int, error) { + runner, err := build(tasks) + if err != nil { + return 2, err + } + + wd, err := os.MkdirTemp(os.TempDir(), "go-task-*") + if err != nil { + return 2, errors.New("could not create a temporary working directory") + } + defer os.RemoveAll(wd) + + workerBin := fmt.Sprintf("%s%ctask-runner", wd, os.PathSeparator) + workerSrc := workerBin + ".go" + os.WriteFile(workerSrc, runner, 0o666) + + cmd := exec.Command("go", "build", "-o", workerBin, workerSrc) + cmd.Stderr = os.Stderr + cmd.Stdin = os.Stdin + cmd.Stdout = os.Stdout + if err := cmd.Run(); err != nil { + return 2, fmt.Errorf("could not build the task runner: %v", err) + } + + cmd = exec.Command(workerBin) + cmd.Stderr = os.Stderr + cmd.Stdin = os.Stdin + cmd.Stdout = os.Stdout + if err := cmd.Run(); err != nil { + if exitErr, ok := err.(*exec.ExitError); ok { + return exitErr.ExitCode(), nil + } else { + return 2, fmt.Errorf("unexpected execution error: %v", err) + } + } + + return 0, nil + } + ) + + tasks, err := parse() + if err != nil { + fmt.Fprintf(os.Stderr, "Syntax error: %s\n", err) + os.Exit(2) + } + + if len(os.Args) < 2 { + fmt.Println("usage:\n go run tasks.go [task2...]") + fmt.Println() + fmt.Println("tasks:") + for _, task := range tasks { + fmt.Printf(" %s\n %s\n", task.name, task.desc) + } + + os.Exit(0) + } + + for _, taskName := range os.Args[1:] { + found := false + for _, task := range tasks { + found = (taskName == task.name) || found + } + + if !found { + fmt.Fprintf(os.Stderr, "Task not found: %q\n", taskName) + os.Exit(2) + } + } + + exitCode, err := run(os.Args[1:]) + if err != nil { + fmt.Fprintln(os.Stderr, err) + } + + os.Exit(exitCode) +} diff --git a/testdata/base.txtar b/testdata/base.txtar new file mode 100644 index 0000000..2679c7a --- /dev/null +++ b/testdata/base.txtar @@ -0,0 +1,12 @@ +exec ghasum help +cp stdout help.txt + +# No command +exec ghasum +cmp stdout help.txt +! stderr . + +# Unknown command +! exec ghasum this-is-definitely-not-a-real-command +cmp stdout help.txt +! stderr . diff --git a/testdata/cache/error.txtar b/testdata/cache/error.txtar new file mode 100644 index 0000000..386d156 --- /dev/null +++ b/testdata/cache/error.txtar @@ -0,0 +1,10 @@ +# Unknown command +! exec ghasum cache this-is-definitely-not-a-real-command +! stdout . +stderr 'unknown command "this-is-definitely-not-a-real-command"' +stderr 'ghasum help cache' + +# Too many commands +! exec ghasum cache clear command2 +! stdout . +stderr 'only one command can be run at the time' diff --git a/testdata/cache/success.txtar b/testdata/cache/success.txtar new file mode 100644 index 0000000..ced25ac --- /dev/null +++ b/testdata/cache/success.txtar @@ -0,0 +1,30 @@ +# Clear - cache directory exists +exec ghasum cache -cache .cache/ clear +stdout 'Ok' +! stderr . +! exists .cache/ + +# Clear - cache directory does not exist +exec ghasum cache -cache .does-not-exist/ clear +stdout 'Ok' +! stderr . +! exists .does-not-exist/ + +# Path - no path specified +exec ghasum cache path +! stdout 'Ok' +stdout . +! stderr . + +# Path - path specified +exec ghasum cache -cache .cache/ path +! stdout 'Ok' +stdout .cache/ +! stderr . + +-- .cache/actions/checkout/v4/.keep -- +This file exist to avoid fetching "actions/checkout@v4" and give the Action a +unique checksum. +-- .cache/actions/setup-go/v5/.keep -- +This file exists to avoid fetching "actions/setup-go@v5" and give the Action a +unique checksum. diff --git a/testdata/cache/usage.txtar b/testdata/cache/usage.txtar new file mode 100644 index 0000000..5313265 --- /dev/null +++ b/testdata/cache/usage.txtar @@ -0,0 +1,12 @@ +exec ghasum help cache +cp stdout help.txt + +# Unknown flag +! exec ghasum cache -this-is-definitely-not-a-real-flag +cmp stdout help.txt +stderr '-this-is-definitely-not-a-real-flag' + +# Too few commands +! exec ghasum cache +cmp stdout help.txt +! stderr . diff --git a/testdata/end-to-end.txtar b/testdata/end-to-end.txtar new file mode 100644 index 0000000..44ffaed --- /dev/null +++ b/testdata/end-to-end.txtar @@ -0,0 +1,61 @@ +cd target + +# Workflow: init -> verify +exec ghasum init -cache ../.cache +exec ghasum verify -cache ../.cache + +# Workflow: update -> verify +mv updated-workflow.yml .github/workflows/workflow.yml +exec ghasum update -cache ../.cache +exec ghasum verify -cache ../.cache + +-- target/.github/workflows/workflow.yml -- +name: Example workflow +on: [push] + +jobs: + example: + name: example + runs-on: ubuntu-22.04 + steps: + - name: Checkout repository + uses: actions/checkout@main + - name: Install Go + uses: actions/setup-go@v5.0.0 + with: + go-version-file: go.mod + - name: golangci-lint + uses: golangci/golangci-lint-action@3a91952 + - name: This step does not use an action + run: Echo 'hello world!' +-- target/updated-workflow.yml -- +name: Example workflow +on: [push] + +jobs: + example: + name: example + runs-on: ubuntu-22.04 + steps: + - name: Checkout repository + uses: actions/checkout@v4.1.1 + - name: Install Go + uses: actions/setup-go@v5.0.0 + with: + go-version-file: go.mod + - name: golangci-lint + uses: golangci/golangci-lint-action@3a91952 + - name: This step does not use an action + run: Echo 'hello world!' +-- .cache/actions/checkout/main/.keep -- +This file exist to avoid fetching "actions/checkout@main" and give the Action a +unique checksum. +-- .cache/actions/checkout/v4.1.1/.keep -- +This file exist to avoid fetching "actions/checkout@v4.1.1" and give the Action +a unique checksum. +-- .cache/actions/setup-go/v5.0.0/.keep -- +This file exists to avoid fetching "actions/setup-go@v5.0.0" and give the Action +a unique checksum. +-- .cache/golangci/golangci-lint-action/3a91952/.keep -- +This file exist to avoid fetching "golangci/golangci-lint-action@3a91952" and +give the Action a unique checksum. diff --git a/testdata/help/error.txtar b/testdata/help/error.txtar new file mode 100644 index 0000000..a36536d --- /dev/null +++ b/testdata/help/error.txtar @@ -0,0 +1,10 @@ +# Unknown command +! exec ghasum help this-is-definitely-not-a-real-command +! stdout . +stderr 'unknown command "this-is-definitely-not-a-real-command"' +stderr 'ghasum help' + +# Too many commands +! exec ghasum help command1 command2 +! stdout . +stderr 'you can ask help for only one command at the time' diff --git a/testdata/help/usage.txtar b/testdata/help/usage.txtar new file mode 100644 index 0000000..80f6324 --- /dev/null +++ b/testdata/help/usage.txtar @@ -0,0 +1,7 @@ +exec ghasum help +cp stdout help.txt + +# Unknown flag +! exec ghasum help -this-is-definitely-not-a-real-flag +cmp stdout help.txt +stderr '-this-is-definitely-not-a-real-flag' diff --git a/testdata/init/error.txtar b/testdata/init/error.txtar new file mode 100644 index 0000000..2c9dcc6 --- /dev/null +++ b/testdata/init/error.txtar @@ -0,0 +1,29 @@ +# Initialized repo +! exec ghasum init initialized/ +! stdout 'Ok' +stderr 'an unexpected error occurred' +stderr 'ghasum is already initialized' + +-- initialized/.github/workflows/gha.sum -- +version 1 + +actions/checkout@main PKruFKnotZi8RQ196H3R7c5bgw9+mfI7BN/h0A7XiV8= +actions/setup-go@v5.0.0 7lPZupz84sSI3T+PiaMr/ML3XPqJaEo7dMaPsQUnM6c= +golangci/golangci-lint-action@3a91952 CVRgC7gGqkOiujfm0VMRKppg/Ztv8FW9GYmyJzcwlCI= +-- initialized/.github/workflows/workflow.yml -- +name: Example workflow +on: [push] + +jobs: + example: + name: example + runs-on: ubuntu-22.04 + steps: + - name: Checkout repository + uses: actions/checkout@v4 + - name: Install Go + uses: actions/setup-go@v5 + with: + go-version-file: go.mod + - name: This step does not use an action + run: Echo 'hello world!' diff --git a/testdata/init/success.txtar b/testdata/init/success.txtar new file mode 100644 index 0000000..6167bd3 --- /dev/null +++ b/testdata/init/success.txtar @@ -0,0 +1,40 @@ +# Success example +exec ghasum init -cache .cache/ target/ +stdout 'Ok' +! stderr . +cmp target/.github/workflows/gha.sum want/gha.sum + +-- want/gha.sum -- +version 1 + +actions/checkout@main PKruFKnotZi8RQ196H3R7c5bgw9+mfI7BN/h0A7XiV8= +actions/setup-go@v5.0.0 7lPZupz84sSI3T+PiaMr/ML3XPqJaEo7dMaPsQUnM6c= +golangci/golangci-lint-action@3a91952 CVRgC7gGqkOiujfm0VMRKppg/Ztv8FW9GYmyJzcwlCI= +-- target/.github/workflows/workflow.yml -- +name: Example workflow +on: [push] + +jobs: + example: + name: example + runs-on: ubuntu-22.04 + steps: + - name: Checkout repository + uses: actions/checkout@main + - name: Install Go + uses: actions/setup-go@v5.0.0 + with: + go-version-file: go.mod + - name: golangci-lint + uses: golangci/golangci-lint-action@3a91952 + - name: This step does not use an action + run: Echo 'hello world!' +-- .cache/actions/checkout/main/.keep -- +This file exist to avoid fetching "actions/checkout@main" and give the Action a +unique checksum. +-- .cache/actions/setup-go/v5.0.0/.keep -- +This file exists to avoid fetching "actions/setup-go@v5.0.0" and give the Action +a unique checksum. +-- .cache/golangci/golangci-lint-action/3a91952/.keep -- +This file exist to avoid fetching "golangci/golangci-lint-action@3a91952" and +give the Action a unique checksum. diff --git a/testdata/init/usage.txtar b/testdata/init/usage.txtar new file mode 100644 index 0000000..88f2744 --- /dev/null +++ b/testdata/init/usage.txtar @@ -0,0 +1,12 @@ +exec ghasum help init +cp stdout help.txt + +# Unknown flag +! exec ghasum init -this-is-definitely-not-a-real-flag +cmp stdout help.txt +stderr '-this-is-definitely-not-a-real-flag' + +# Too many targets +! exec ghasum init target1 target2 +cmp stdout help.txt +! stderr . diff --git a/testdata/update/error.txtar b/testdata/update/error.txtar new file mode 100644 index 0000000..c112e54 --- /dev/null +++ b/testdata/update/error.txtar @@ -0,0 +1,31 @@ +# Repo without actions +! exec ghasum update no-actions/ +! stdout 'Ok' +stderr 'an unexpected error occurred' +stderr 'ghasum has not yet been initialized' + +# Uninitialized repo with Actions +! exec ghasum update uninitialized/ +! stdout 'Ok' +stderr 'an unexpected error occurred' +stderr 'ghasum has not yet been initialized' + +-- no-actions/.keep -- +This file exists to create a repo that does not use Github Actions. +-- uninitialized/.github/workflows/workflow.yml -- +name: Example workflow +on: [push] + +jobs: + example: + name: example + runs-on: ubuntu-22.04 + steps: + - name: Checkout repository + uses: actions/checkout@v4 + - name: Install Go + uses: actions/setup-go@v5 + with: + go-version-file: go.mod + - name: This step does not use an action + run: Echo 'hello world!' diff --git a/testdata/update/success.txtar b/testdata/update/success.txtar new file mode 100644 index 0000000..01a3421 --- /dev/null +++ b/testdata/update/success.txtar @@ -0,0 +1,84 @@ +# Update unnecessary +cmp unchanged/.github/workflows/gha.sum want/gha.sum + +exec ghasum update -cache .cache/ unchanged/ +stdout 'Ok' +! stderr . +cmp unchanged/.github/workflows/gha.sum want/gha.sum + +# Update necessary +! cmp changed/.github/workflows/gha.sum want/gha.sum + +exec ghasum update -cache .cache/ changed/ +stdout 'Ok' +! stderr . +cmp changed/.github/workflows/gha.sum want/gha.sum + +-- want/gha.sum -- +version 1 + +actions/checkout@v4.1.1 KsR9XQGH7ydTl01vlD8pIZrXhkzXyjcnzhmP+/KaJZI= +actions/setup-go@v5.0.0 7lPZupz84sSI3T+PiaMr/ML3XPqJaEo7dMaPsQUnM6c= +golangci/golangci-lint-action@3a91952 CVRgC7gGqkOiujfm0VMRKppg/Ztv8FW9GYmyJzcwlCI= +-- unchanged/.github/workflows/gha.sum -- +version 1 + +actions/checkout@v4.1.1 KsR9XQGH7ydTl01vlD8pIZrXhkzXyjcnzhmP+/KaJZI= +actions/setup-go@v5.0.0 7lPZupz84sSI3T+PiaMr/ML3XPqJaEo7dMaPsQUnM6c= +golangci/golangci-lint-action@3a91952 CVRgC7gGqkOiujfm0VMRKppg/Ztv8FW9GYmyJzcwlCI= +-- unchanged/.github/workflows/workflow.yml -- +name: Example workflow +on: [push] + +jobs: + example: + name: example + runs-on: ubuntu-22.04 + steps: + - name: Checkout repository + uses: actions/checkout@v4.1.1 + - name: Install Go + uses: actions/setup-go@v5.0.0 + with: + go-version-file: go.mod + - name: golangci-lint + uses: golangci/golangci-lint-action@3a91952 + - name: This step does not use an action + run: Echo 'hello world!' +-- changed/.github/workflows/gha.sum -- +version 1 + +actions/checkout@main PKruFKnotZi8RQ196H3R7c5bgw9+mfI7BN/h0A7XiV8= +actions/setup-go@v5.0.0 7lPZupz84sSI3T+PiaMr/ML3XPqJaEo7dMaPsQUnM6c= +golangci/golangci-lint-action@3a91952 CVRgC7gGqkOiujfm0VMRKppg/Ztv8FW9GYmyJzcwlCI= +-- changed/.github/workflows/workflow.yml -- +name: Example workflow +on: [push] + +jobs: + example: + name: example + runs-on: ubuntu-22.04 + steps: + - name: Checkout repository + uses: actions/checkout@v4.1.1 + - name: Install Go + uses: actions/setup-go@v5.0.0 + with: + go-version-file: go.mod + - name: golangci-lint + uses: golangci/golangci-lint-action@3a91952 + - name: This step does not use an action + run: Echo 'hello world!' +-- .cache/actions/checkout/main/.keep -- +This file exist to avoid fetching "actions/checkout@main" and give the Action a +unique checksum. +-- .cache/actions/checkout/v4.1.1/.keep -- +This file exist to avoid fetching "actions/checkout@v4.1.1" and give the Action +a unique checksum. +-- .cache/actions/setup-go/v5.0.0/.keep -- +This file exists to avoid fetching "actions/setup-go@v5.0.0" and give the Action +a unique checksum. +-- .cache/golangci/golangci-lint-action/3a91952/.keep -- +This file exist to avoid fetching "golangci/golangci-lint-action@3a91952" and +give the Action a unique checksum. diff --git a/testdata/update/usage.txtar b/testdata/update/usage.txtar new file mode 100644 index 0000000..946bb17 --- /dev/null +++ b/testdata/update/usage.txtar @@ -0,0 +1,12 @@ +exec ghasum help update +cp stdout help.txt + +# Unknown flag +! exec ghasum update -this-is-definitely-not-a-real-flag +cmp stdout help.txt +stderr '-this-is-definitely-not-a-real-flag' + +# Too many targets +! exec ghasum update target1 target2 +cmp stdout help.txt +! stderr . diff --git a/testdata/verify/error.txtar b/testdata/verify/error.txtar new file mode 100644 index 0000000..9642171 --- /dev/null +++ b/testdata/verify/error.txtar @@ -0,0 +1,31 @@ +# Repo without actions +! exec ghasum verify no-actions/ +! stdout 'Ok' +stderr 'an unexpected error occurred' +stderr 'ghasum has not yet been initialized' + +# Uninitialized repo with Actions +! exec ghasum verify uninitialized/ +! stdout 'Ok' +stderr 'an unexpected error occurred' +stderr 'ghasum has not yet been initialized' + +-- no-actions/.keep -- +This file exists to create a repo that does not use Github Actions. +-- uninitialized/.github/workflows/workflow.yml -- +name: Example workflow +on: [push] + +jobs: + example: + name: example + runs-on: ubuntu-22.04 + steps: + - name: Checkout repository + uses: actions/checkout@v4 + - name: Install Go + uses: actions/setup-go@v5 + with: + go-version-file: go.mod + - name: This step does not use an action + run: Echo 'hello world!' diff --git a/testdata/verify/problems.txtar b/testdata/verify/problems.txtar new file mode 100644 index 0000000..d4aa34d --- /dev/null +++ b/testdata/verify/problems.txtar @@ -0,0 +1,61 @@ +# Checksum mismatch +! exec ghasum verify -cache .cache/ mismatch/ +stdout . +! stdout 'Ok' +! stderr . + +# Checksum missing +! exec ghasum verify -cache .cache/ missing/ +stdout . +! stdout 'Ok' +! stderr . + +-- mismatch/.github/workflows/gha.sum -- +version 1 + +actions/checkout@v4 xCHyD2IBscJ1q4pfZ3pVXntv0HgGM8tQrG2h3ZHQeuk= +actions/setup-go@v5 /ChzMZC1jsCd/aTotskAS7hl1cX9/M5XsV7mJHCMuME= +-- mismatch/.github/workflows/workflow.yml -- +name: Example workflow +on: [push] + +jobs: + example: + name: example + runs-on: ubuntu-22.04 + steps: + - name: Checkout repository + uses: actions/checkout@v4 + - name: Install Go + uses: actions/setup-go@v5 + with: + go-version-file: go.mod + - name: This step does not use an action + run: Echo 'hello world!' +-- missing/.github/workflows/gha.sum -- +version 1 + +actions/checkout@v4 NQDx6JqrrKyfeOkbY1jy5XFTgfP3P/r9G9l17ENtfBA= +-- missing/.github/workflows/workflow.yml -- +name: Example workflow +on: [push] + +jobs: + example: + name: example + runs-on: ubuntu-22.04 + steps: + - name: Checkout repository + uses: actions/checkout@v4 + - name: Install Go + uses: actions/setup-go@v5 + with: + go-version-file: go.mod + - name: This step does not use an action + run: Echo 'hello world!' +-- .cache/actions/checkout/v4/.keep -- +This file exist to avoid fetching "actions/checkout@v4" and give the Action a +unique checksum. +-- .cache/actions/setup-go/v5/.keep -- +This file exists to avoid fetching "actions/setup-go@v5" and give the Action a +unique checksum. diff --git a/testdata/verify/success.txtar b/testdata/verify/success.txtar new file mode 100644 index 0000000..fbe8384 --- /dev/null +++ b/testdata/verify/success.txtar @@ -0,0 +1,67 @@ +# Checksums match exactly +exec ghasum verify -cache .cache/ up-to-date/ +stdout 'Ok' +! stderr . + +# Redundant checksum stored +exec ghasum verify -cache .cache/ redundant/ +stdout 'Ok' +! stderr . + +-- up-to-date/.github/workflows/gha.sum -- +version 1 + +actions/checkout@main PKruFKnotZi8RQ196H3R7c5bgw9+mfI7BN/h0A7XiV8= +actions/setup-go@v5.0.0 7lPZupz84sSI3T+PiaMr/ML3XPqJaEo7dMaPsQUnM6c= +golangci/golangci-lint-action@3a91952 CVRgC7gGqkOiujfm0VMRKppg/Ztv8FW9GYmyJzcwlCI= +-- up-to-date/.github/workflows/workflow.yml -- +name: Example workflow +on: [push] + +jobs: + example: + name: example + runs-on: ubuntu-22.04 + steps: + - name: Checkout repository + uses: actions/checkout@main + - name: Install Go + uses: actions/setup-go@v5.0.0 + with: + go-version-file: go.mod + - name: golangci-lint + uses: golangci/golangci-lint-action@3a91952 + - name: This step does not use an action + run: Echo 'hello world!' +-- redundant/.github/workflows/gha.sum -- +version 1 + +actions/checkout@main PKruFKnotZi8RQ196H3R7c5bgw9+mfI7BN/h0A7XiV8= +actions/setup-go@v5.0.0 7lPZupz84sSI3T+PiaMr/ML3XPqJaEo7dMaPsQUnM6c= +golangci/golangci-lint-action@3a91952 this-action-is-not-used-in-the-repo +-- redundant/.github/workflows/workflow.yml -- +name: Example workflow +on: [push] + +jobs: + example: + name: example + runs-on: ubuntu-22.04 + steps: + - name: Checkout repository + uses: actions/checkout@main + - name: Install Go + uses: actions/setup-go@v5.0.0 + with: + go-version-file: go.mod + - name: This step does not use an action + run: Echo 'hello world!' +-- .cache/actions/checkout/main/.keep -- +This file exist to avoid fetching "actions/checkout@main" and give the Action a +unique checksum. +-- .cache/actions/setup-go/v5.0.0/.keep -- +This file exists to avoid fetching "actions/setup-go@v5.0.0" and give the Action +a unique checksum. +-- .cache/golangci/golangci-lint-action/3a91952/.keep -- +This file exist to avoid fetching "golangci/golangci-lint-action@3a91952" and +give the Action a unique checksum. diff --git a/testdata/verify/usage.txtar b/testdata/verify/usage.txtar new file mode 100644 index 0000000..9e8a963 --- /dev/null +++ b/testdata/verify/usage.txtar @@ -0,0 +1,12 @@ +exec ghasum help verify +cp stdout help.txt + +# Unknown flag +! exec ghasum verify -this-is-definitely-not-a-real-flag +cmp stdout help.txt +stderr '-this-is-definitely-not-a-real-flag' + +# Too many targets +! exec ghasum verify target1 target2 +cmp stdout help.txt +! stderr . diff --git a/testdata/version/success.txtar b/testdata/version/success.txtar new file mode 100644 index 0000000..481e636 --- /dev/null +++ b/testdata/version/success.txtar @@ -0,0 +1,4 @@ +# Program version +exec ghasum version +stdout . +! stderr . diff --git a/testdata/version/usage.txtar b/testdata/version/usage.txtar new file mode 100644 index 0000000..a32d859 --- /dev/null +++ b/testdata/version/usage.txtar @@ -0,0 +1,12 @@ +exec ghasum help version +cp stdout help.txt + +# Unknown flag +! exec ghasum version -this-is-definitely-not-a-real-flag +cmp stdout help.txt +stderr '-this-is-definitely-not-a-real-flag' + +# Unknown argument +! exec ghasum version argument +cmp stdout help.txt +! stderr . diff --git a/tools.go b/tools.go new file mode 100644 index 0000000..156c08e --- /dev/null +++ b/tools.go @@ -0,0 +1,52 @@ +// MIT No Attribution +// +// Copyright (c) 2024 Eric Cornelissen +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +//go:build tools + +package main + +import ( + _ "4d63.com/gochecknoinits" + _ "github.com/alexkohler/dogsled/cmd/dogsled" + _ "github.com/alexkohler/nakedret/v2/cmd/nakedret" + _ "github.com/alexkohler/prealloc" + _ "github.com/alexkohler/unimport" + _ "github.com/butuzov/ireturn/cmd/ireturn" + _ "github.com/catenacyber/perfsprint" + _ "github.com/dkorunic/betteralign/cmd/betteralign" + _ "github.com/go-critic/go-critic/cmd/gocritic" + _ "github.com/gordonklaus/ineffassign" + _ "github.com/jgautheron/goconst/cmd/goconst" + _ "github.com/kisielk/errcheck" + _ "github.com/kunwardeep/paralleltest" + _ "github.com/mdempsky/unconvert" + _ "github.com/nishanths/exhaustive/cmd/exhaustive" + _ "github.com/polyfloyd/go-errorlint" + _ "github.com/remyoudompheng/go-misc/deadcode" + _ "github.com/tetafro/godot/cmd/godot" + _ "github.com/tomarrell/wrapcheck/v2/cmd/wrapcheck" + _ "github.com/ultraware/whitespace/cmd/whitespace" + _ "gitlab.com/bosi/decorder/cmd/decorder" + _ "go.uber.org/nilaway/cmd/nilaway" + _ "golang.org/x/tools/cmd/goimports" + _ "golang.org/x/tools/go/analysis/passes/shadow/cmd/shadow" + _ "golang.org/x/vuln/cmd/govulncheck" + _ "honnef.co/go/tools/cmd/staticcheck" + _ "mvdan.cc/unparam" +)