mirror of
https://github.com/ivuorinen/a.git
synced 2026-01-26 03:24:07 +00:00
Initial commit
This commit is contained in:
@@ -1,21 +1,14 @@
|
||||
root = true
|
||||
|
||||
[*]
|
||||
charset = utf-8
|
||||
end_of_line = lf
|
||||
indent_size = 2
|
||||
indent_style = space
|
||||
insert_final_newline = true
|
||||
max_line_length = 160
|
||||
tab_width = 2
|
||||
trim_trailing_whitespace = true
|
||||
|
||||
[{*.md}]
|
||||
indent_size = 2
|
||||
tab_width = 2
|
||||
max_line_length = 160
|
||||
trim_trailing_whitespace = false
|
||||
insert_final_newline = true
|
||||
max_line_length = 120
|
||||
|
||||
[{*.mk,GNUmakefile,makefile}]
|
||||
tab_width = 4
|
||||
[*.go]
|
||||
indent_style = tab
|
||||
indent_width = 2
|
||||
|
||||
[{Makefile,go.mod,go.sum}]
|
||||
indent_style = tab
|
||||
|
||||
3
.github/ISSUE_TEMPLATE/bug_report.md
vendored
3
.github/ISSUE_TEMPLATE/bug_report.md
vendored
@@ -12,6 +12,7 @@ A clear and concise description of what the bug is.
|
||||
|
||||
**To Reproduce**
|
||||
Steps to reproduce the behavior:
|
||||
|
||||
1. Go to '...'
|
||||
2. Click on '....'
|
||||
3. Scroll down to '....'
|
||||
@@ -24,11 +25,13 @@ A clear and concise description of what you expected to happen.
|
||||
If applicable, add screenshots to help explain your problem.
|
||||
|
||||
**Desktop (please complete the following information):**
|
||||
|
||||
- OS: [e.g. iOS]
|
||||
- Browser [e.g. chrome, safari]
|
||||
- Version [e.g. 22]
|
||||
|
||||
**Smartphone (please complete the following information):**
|
||||
|
||||
- Device: [e.g. iPhone6]
|
||||
- OS: [e.g. iOS8.1]
|
||||
- Browser [e.g. stock browser, safari]
|
||||
|
||||
8
.github/workflows/codeql.yml
vendored
8
.github/workflows/codeql.yml
vendored
@@ -1,7 +1,6 @@
|
||||
---
|
||||
# yaml-language-server: $schema=https://json.schemastore.org/github-workflow.json
|
||||
name: 'CodeQL'
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: ['main']
|
||||
@@ -10,36 +9,29 @@ on:
|
||||
schedule:
|
||||
- cron: '30 1 * * 0' # Run at 1:30 AM UTC every Sunday
|
||||
merge_group:
|
||||
|
||||
permissions:
|
||||
actions: read
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
analyze:
|
||||
name: Analyze
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
security-events: write
|
||||
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
language: ['javascript'] # Add languages used in your actions
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@181d5eefc20863364f96762470ba6f862bdef56b # v3.29.2
|
||||
with:
|
||||
languages: ${{ matrix.language }}
|
||||
queries: security-and-quality
|
||||
|
||||
- name: Autobuild
|
||||
uses: github/codeql-action/autobuild@181d5eefc20863364f96762470ba6f862bdef56b # v3.29.2
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@181d5eefc20863364f96762470ba6f862bdef56b # v3.29.2
|
||||
with:
|
||||
|
||||
5
.github/workflows/pr-lint.yml
vendored
5
.github/workflows/pr-lint.yml
vendored
@@ -1,19 +1,15 @@
|
||||
---
|
||||
# yaml-language-server: $schema=https://json.schemastore.org/github-workflow.json
|
||||
name: Lint Code Base
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [master, main]
|
||||
pull_request:
|
||||
branches: [master, main]
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions: read-all
|
||||
|
||||
jobs:
|
||||
Linter:
|
||||
name: PR Lint
|
||||
@@ -23,7 +19,6 @@ jobs:
|
||||
statuses: write
|
||||
contents: read
|
||||
packages: read
|
||||
|
||||
steps:
|
||||
- name: Run PR Lint
|
||||
# https://github.com/ivuorinen/actions
|
||||
|
||||
4
.github/workflows/stale.yml
vendored
4
.github/workflows/stale.yml
vendored
@@ -1,23 +1,19 @@
|
||||
---
|
||||
# yaml-language-server: $schema=https://json.schemastore.org/github-workflow.json
|
||||
name: Stale
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: '0 8 * * *' # Every day at 08:00
|
||||
workflow_call:
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
packages: read
|
||||
statuses: read
|
||||
|
||||
jobs:
|
||||
stale:
|
||||
name: 🧹 Clean up stale issues and PRs
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
permissions:
|
||||
contents: write # only for delete-branch option
|
||||
issues: write
|
||||
|
||||
6
.github/workflows/sync-labels.yml
vendored
6
.github/workflows/sync-labels.yml
vendored
@@ -1,7 +1,6 @@
|
||||
---
|
||||
# yaml-language-server: $schema=https://json.schemastore.org/github-workflow.json
|
||||
name: Sync Labels
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
@@ -15,23 +14,18 @@ on:
|
||||
workflow_call:
|
||||
workflow_dispatch:
|
||||
merge_group:
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions: read-all
|
||||
|
||||
jobs:
|
||||
labels:
|
||||
name: ♻️ Sync Labels
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
issues: write
|
||||
|
||||
steps:
|
||||
- name: ⤵️ Checkout Repository
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
|
||||
150
.gitignore
vendored
150
.gitignore
vendored
@@ -1,134 +1,44 @@
|
||||
.php-cs-fixer.cache
|
||||
.php-cs-fixer.php
|
||||
composer.phar
|
||||
/vendor/
|
||||
.phpunit.result.cache
|
||||
.phpunit.cache
|
||||
/app/phpunit.xml
|
||||
/phpunit.xml
|
||||
/build/
|
||||
logs
|
||||
*.iws
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
lerna-debug.log*
|
||||
.pnpm-debug.log*
|
||||
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
|
||||
pids
|
||||
*.pem
|
||||
*.pid
|
||||
*.seed
|
||||
*.pid.lock
|
||||
lib-cov
|
||||
coverage
|
||||
*.lcov
|
||||
.nyc_output
|
||||
.grunt
|
||||
bower_components
|
||||
.lock-wscript
|
||||
build/Release
|
||||
node_modules/
|
||||
jspm_packages/
|
||||
web_modules/
|
||||
*.tsbuildinfo
|
||||
.npm
|
||||
.eslintcache
|
||||
.stylelintcache
|
||||
.rpt2_cache/
|
||||
.rts2_cache_cjs/
|
||||
.rts2_cache_es/
|
||||
.rts2_cache_umd/
|
||||
.node_repl_history
|
||||
*.tgz
|
||||
.yarn-integrity
|
||||
.env
|
||||
.env.development.local
|
||||
.env.test.local
|
||||
.env.production.local
|
||||
.env.local
|
||||
.cache
|
||||
.parcel-cache
|
||||
.next
|
||||
out
|
||||
.nuxt
|
||||
dist
|
||||
.cache/
|
||||
.vuepress/dist
|
||||
.temp
|
||||
.docusaurus
|
||||
.serverless/
|
||||
.fusebox/
|
||||
.dynamodb/
|
||||
.tern-port
|
||||
.vscode-test
|
||||
.yarn/cache
|
||||
.yarn/unplugged
|
||||
.yarn/build-state.yml
|
||||
.yarn/install-state.gz
|
||||
.pnp.*
|
||||
[._]*.s[a-v][a-z]
|
||||
!*.svg # comment out if you don't need vector files
|
||||
[._]*.sw[a-p]
|
||||
[._]s[a-rt-v][a-z]
|
||||
[._]ss[a-gi-z]
|
||||
[._]sw[a-p]
|
||||
Session.vim
|
||||
Sessionx.vim
|
||||
.netrwhist
|
||||
*~
|
||||
tags
|
||||
[._]*.un~
|
||||
.idea/**/workspace.xml
|
||||
.idea/**/tasks.xml
|
||||
.idea/**/usage.statistics.xml
|
||||
.idea/**/dictionaries
|
||||
.idea/**/shelf
|
||||
.DS_Store
|
||||
.env
|
||||
.env*.local
|
||||
.env.development.local
|
||||
.env.local
|
||||
.env.production.local
|
||||
.env.test.local
|
||||
.idea/**/aws.xml
|
||||
.idea/**/contentModel.xml
|
||||
.idea/**/dataSources/
|
||||
.idea/**/dataSources.ids
|
||||
.idea/**/dataSources.local.xml
|
||||
.idea/**/sqlDataSources.xml
|
||||
.idea/**/dynamic.xml
|
||||
.idea/**/uiDesigner.xml
|
||||
.idea/**/dataSources/
|
||||
.idea/**/dbnavigator.xml
|
||||
.idea/**/dictionaries
|
||||
.idea/**/dynamic.xml
|
||||
.idea/**/gradle.xml
|
||||
.idea/**/libraries
|
||||
cmake-build-*/
|
||||
.idea/**/mongoSettings.xml
|
||||
*.iws
|
||||
out/
|
||||
.idea_modules/
|
||||
atlassian-ide-plugin.xml
|
||||
.idea/**/shelf
|
||||
.idea/**/sqlDataSources.xml
|
||||
.idea/**/tasks.xml
|
||||
.idea/**/uiDesigner.xml
|
||||
.idea/**/usage.statistics.xml
|
||||
.idea/**/workspace.xml
|
||||
.idea/caches/build_file_checksums.ser
|
||||
.idea/httpRequests
|
||||
.idea/replstate.xml
|
||||
.idea/sonarlint/
|
||||
com_crashlytics_export_strings.xml
|
||||
crashlytics.properties
|
||||
crashlytics-build.properties
|
||||
fabric.properties
|
||||
.idea/httpRequests
|
||||
.idea/caches/build_file_checksums.ser
|
||||
npm-debug.log
|
||||
yarn-error.log
|
||||
bootstrap/compiled.php
|
||||
app/storage/
|
||||
public/storage
|
||||
public/hot
|
||||
public_html/storage
|
||||
public_html/hot
|
||||
storage/*.key
|
||||
Homestead.yaml
|
||||
Homestead.json
|
||||
/.vagrant
|
||||
/node_modules
|
||||
/.pnp
|
||||
.pnp.js
|
||||
/coverage
|
||||
/.next/
|
||||
/out/
|
||||
/build
|
||||
.DS_Store
|
||||
*.pem
|
||||
.env*.local
|
||||
.vercel
|
||||
next-env.d.ts
|
||||
.idea_modules/
|
||||
.netrwhist
|
||||
.vscode-test
|
||||
Session.vim
|
||||
Sessionx.vim
|
||||
[._]*.un~
|
||||
coverage*
|
||||
logs
|
||||
out/
|
||||
tags
|
||||
|
||||
1
.go-version
Normal file
1
.go-version
Normal file
@@ -0,0 +1 @@
|
||||
1.23.0
|
||||
118
.golangci.yml
Normal file
118
.golangci.yml
Normal file
@@ -0,0 +1,118 @@
|
||||
---
|
||||
# yaml-language-server: $schema=https://json.schemastore.org/golangci-lint.json
|
||||
# golangci-lint configuration for f2b project
|
||||
# https://golangci-lint.run/usage/configuration/
|
||||
|
||||
version: "2"
|
||||
run:
|
||||
timeout: 5m
|
||||
modules-download-mode: readonly
|
||||
go: "1.21"
|
||||
|
||||
linters:
|
||||
enable:
|
||||
# Essential linters
|
||||
- errcheck # Error checking
|
||||
- govet # Go vet
|
||||
- ineffassign # Inefficient assignment checking
|
||||
- staticcheck # Static code analysis
|
||||
- unused # Unused variable checking
|
||||
- lll # Line length checking
|
||||
- gosec # Security checking
|
||||
- usetesting # Unit testing
|
||||
- revive # Code style checking
|
||||
|
||||
# Code quality linters
|
||||
- misspell # Spell checking
|
||||
- unconvert # Unconvert checking
|
||||
- gocyclo # Cyclomatic complexity checking
|
||||
- prealloc # Preallocation checking
|
||||
- bodyclose # Body close checking
|
||||
- rowserrcheck # Rows error checking
|
||||
- sqlclosecheck # SQL close checking
|
||||
- durationcheck # Duration checking
|
||||
- errorlint # Error linting
|
||||
- predeclared # Predeclared identifier checking
|
||||
- wastedassign # Wasted assignment checking
|
||||
- containedctx # Contained context checking
|
||||
- contextcheck # Context checking
|
||||
- errname # Error name checking
|
||||
- nilnil # Nil nil checking
|
||||
- thelper # Helper function checking
|
||||
- usestdlibvars # Use standard library variables
|
||||
- whitespace # Whitespace checking
|
||||
- godox # TODO/FIXME/etc comments
|
||||
|
||||
disable:
|
||||
# Disable overly strict linters for this project
|
||||
- varnamelen # Variable name length checking
|
||||
- tagliatelle # Struct tag format checking
|
||||
- makezero # Make zero checking
|
||||
- testpackage # Separate test package requirement
|
||||
- paralleltest # Parallel test requirement
|
||||
- forcetypeassert # Force type assertion
|
||||
- ireturn # Return interface checking
|
||||
- nlreturn # New line return checking
|
||||
- cyclop # Cyclomatic complexity (covered by gocyclo)
|
||||
- funlen # Function length checking
|
||||
- gocognit # Cognitive complexity checking
|
||||
- maintidx # Maintainability index
|
||||
- nestif # Nested if checking
|
||||
- wsl # Whitespace linter (too strict)
|
||||
- gocritic # Too many style opinions
|
||||
- nakedret # Naked returns
|
||||
- nolintlint # Nolint directive checking
|
||||
- noctx # Context checking
|
||||
|
||||
settings:
|
||||
errcheck:
|
||||
check-type-assertions: false
|
||||
check-blank: false
|
||||
|
||||
govet:
|
||||
enable-all: true
|
||||
disable:
|
||||
- fieldalignment # Can be too strict for simple structs
|
||||
- shadow # Variable shadowing can be acceptable
|
||||
|
||||
gocyclo:
|
||||
min-complexity: 20
|
||||
|
||||
misspell:
|
||||
locale: US
|
||||
|
||||
prealloc:
|
||||
simple: true
|
||||
range-loops: true
|
||||
for-loops: false
|
||||
|
||||
errorlint:
|
||||
errorf: false # Allow %v instead of %w for some cases
|
||||
|
||||
lll:
|
||||
line-length: 120
|
||||
|
||||
formatters:
|
||||
enable:
|
||||
- gofmt
|
||||
- goimports
|
||||
- golines
|
||||
|
||||
settings:
|
||||
gofmt:
|
||||
simplify: true
|
||||
goimports:
|
||||
local-prefixes:
|
||||
- github.com/ivuorinen/a
|
||||
golines:
|
||||
max-len: 120
|
||||
tab-len: 4
|
||||
shorten-comments: false
|
||||
reformat-tags: true
|
||||
chain-split-dots: true
|
||||
|
||||
issues:
|
||||
max-issues-per-linter: 0
|
||||
max-same-issues: 0
|
||||
new: false
|
||||
fix: true
|
||||
241
.goreleaser.yml
Normal file
241
.goreleaser.yml
Normal file
@@ -0,0 +1,241 @@
|
||||
---
|
||||
# yaml-language-server: $schema=https://goreleaser.com/static/schema.json
|
||||
# vim: set ts=2 sw=2 tw=0 fo=jcroql
|
||||
# GoReleaser configuration
|
||||
# Documentation: https://goreleaser.com/customization/
|
||||
version: 2
|
||||
|
||||
# Set the project name
|
||||
project_name: a
|
||||
|
||||
# Clean dist folder before build
|
||||
before:
|
||||
hooks:
|
||||
- go mod tidy
|
||||
- go generate ./...
|
||||
|
||||
# Build configuration
|
||||
builds:
|
||||
- id: a
|
||||
main: .
|
||||
binary: a
|
||||
|
||||
# Custom ldflags
|
||||
ldflags:
|
||||
- -s -w
|
||||
- -X github.com/ivuorinen/a/cmd.version={{.Version}}
|
||||
- -X github.com/ivuorinen/a/cmd.commit={{.Commit}}
|
||||
- -X github.com/ivuorinen/a/cmd.date={{.Date}}
|
||||
- -X github.com/ivuorinen/a/cmd.builtBy=goreleaser
|
||||
|
||||
# Build for multiple platforms
|
||||
goos:
|
||||
- linux
|
||||
- darwin
|
||||
- freebsd
|
||||
- openbsd
|
||||
- netbsd
|
||||
|
||||
goarch:
|
||||
- amd64
|
||||
- arm64
|
||||
- arm
|
||||
- "386"
|
||||
|
||||
goarm:
|
||||
- "6"
|
||||
- "7"
|
||||
|
||||
# Skip certain combinations
|
||||
ignore:
|
||||
- goos: darwin
|
||||
goarch: "386"
|
||||
- goos: darwin
|
||||
goarch: arm
|
||||
- goos: freebsd
|
||||
goarch: arm
|
||||
- goos: openbsd
|
||||
goarch: arm
|
||||
- goos: netbsd
|
||||
goarch: arm
|
||||
|
||||
# Set environment variables
|
||||
env:
|
||||
- CGO_ENABLED=0
|
||||
|
||||
# Custom build tags
|
||||
tags:
|
||||
- netgo
|
||||
- osusergo
|
||||
|
||||
# Archive configuration
|
||||
archives:
|
||||
- id: a
|
||||
formats: ["binary", "tar.gz"]
|
||||
|
||||
# Archive format
|
||||
format_overrides:
|
||||
- goos: windows
|
||||
format: zip
|
||||
|
||||
# Files to include in archive
|
||||
files:
|
||||
- LICENSE.md
|
||||
- README.md
|
||||
- CHANGELOG.md
|
||||
- docs/*
|
||||
|
||||
# Checksum configuration
|
||||
checksum:
|
||||
name_template: "checksums.txt"
|
||||
algorithm: sha256
|
||||
|
||||
# Snapshot configuration
|
||||
snapshot:
|
||||
name_template: "{{ incpatch .Version }}-next"
|
||||
|
||||
# Release configuration
|
||||
release:
|
||||
github:
|
||||
owner: ivuorinen
|
||||
name: a
|
||||
|
||||
# Release notes
|
||||
header: |
|
||||
## a v{{ .Version }} ({{ .Date }})
|
||||
|
||||
A robust command-line interface (CLI) wrapper around the age encryption tool
|
||||
|
||||
footer: |
|
||||
## Installation
|
||||
|
||||
### Using Go
|
||||
```bash
|
||||
go install github.com/ivuorinen/a@latest
|
||||
```
|
||||
|
||||
### Using Homebrew (macOS/Linux)
|
||||
```bash
|
||||
brew tap ivuorinen/tap
|
||||
brew install a
|
||||
```
|
||||
|
||||
### Manual Download
|
||||
Download the appropriate binary for your platform from the assets below.
|
||||
|
||||
## Documentation
|
||||
|
||||
See the [README](https://github.com/ivuorinen/a#readme) for usage instructions.
|
||||
|
||||
# Automatically generate release notes
|
||||
|
||||
make_latest: true
|
||||
|
||||
# Changelog configuration
|
||||
changelog:
|
||||
sort: asc
|
||||
use: github
|
||||
filters:
|
||||
exclude:
|
||||
- "^docs:"
|
||||
- "^test:"
|
||||
- "^chore:"
|
||||
- "typo"
|
||||
- "Merge pull request"
|
||||
- "Merge branch"
|
||||
groups:
|
||||
- title: "🚀 Features"
|
||||
regexp: "^feat"
|
||||
- title: "🐛 Bug Fixes"
|
||||
regexp: "^fix"
|
||||
- title: "🔒 Security"
|
||||
regexp: "^security"
|
||||
- title: "⚡ Performance"
|
||||
regexp: "^perf"
|
||||
- title: "♻️ Refactoring"
|
||||
regexp: "^refactor"
|
||||
- title: "Other changes"
|
||||
|
||||
# Homebrew tap configuration
|
||||
brews:
|
||||
- name: a
|
||||
repository:
|
||||
owner: ivuorinen
|
||||
name: homebrew-tap
|
||||
branch: main
|
||||
token: "{{ .Env.HOMEBREW_TAP_GITHUB_TOKEN }}"
|
||||
|
||||
commit_author:
|
||||
name: goreleaserbot
|
||||
email: bot@goreleaser.com
|
||||
|
||||
commit_msg_template: "Brew formula update for {{ .ProjectName }} version {{ .Tag }}"
|
||||
|
||||
homepage: "https://github.com/ivuorinen/a"
|
||||
description: "Modern, secure Go-based CLI tool for managing Fail2Ban jails and bans"
|
||||
license: "MIT"
|
||||
|
||||
dependencies:
|
||||
- name: go
|
||||
type: optional
|
||||
|
||||
test: |
|
||||
system "#{bin}/a", "version"
|
||||
|
||||
install: |
|
||||
bin.install "a"
|
||||
|
||||
# NFPM configuration for Linux packages
|
||||
nfpms:
|
||||
- id: a
|
||||
package_name: a
|
||||
vendor: ivuorinen
|
||||
homepage: https://github.com/ivuorinen/a
|
||||
maintainer: ivuorinen
|
||||
description: Modern, secure Go-based CLI tool for managing Fail2Ban jails and bans
|
||||
license: MIT
|
||||
|
||||
formats:
|
||||
- deb
|
||||
- rpm
|
||||
- apk
|
||||
|
||||
bindir: /usr/bin
|
||||
|
||||
contents:
|
||||
- src: ./LICENSE.md
|
||||
dst: /usr/share/doc/a/LICENSE.md
|
||||
- src: ./README.md
|
||||
dst: /usr/share/doc/a/README.md
|
||||
|
||||
scripts:
|
||||
postinstall: |
|
||||
#!/bin/sh
|
||||
echo "a has been installed. Run 'a --help' to get started."
|
||||
|
||||
# Docker configuration
|
||||
dockers:
|
||||
- image_templates:
|
||||
- "ghcr.io/ivuorinen/a:{{ .Tag }}"
|
||||
- "ghcr.io/ivuorinen/a:v{{ .Major }}"
|
||||
- "ghcr.io/ivuorinen/a:v{{ .Major }}.{{ .Minor }}"
|
||||
- "ghcr.io/ivuorinen/a:latest"
|
||||
|
||||
dockerfile: |
|
||||
FROM alpine:latest
|
||||
RUN apk --no-cache add ca-certificates
|
||||
COPY a /usr/local/bin/
|
||||
ENTRYPOINT ["a"]
|
||||
|
||||
build_flag_templates:
|
||||
- "--pull"
|
||||
- "--label=org.opencontainers.image.created={{.Date}}"
|
||||
- "--label=org.opencontainers.image.title={{.ProjectName}}"
|
||||
- "--label=org.opencontainers.image.revision={{.FullCommit}}"
|
||||
- "--label=org.opencontainers.image.version={{.Version}}"
|
||||
- "--label=org.opencontainers.image.source={{.GitURL}}"
|
||||
- "--platform=linux/amd64"
|
||||
|
||||
# Announce releases
|
||||
announce:
|
||||
skip: false
|
||||
@@ -2,7 +2,6 @@
|
||||
# Configuration file for MegaLinter
|
||||
# See all available variables at
|
||||
# https://megalinter.io/configuration/ and in linters documentation
|
||||
|
||||
APPLY_FIXES: all
|
||||
SHOW_ELAPSED_TIME: false # Show elapsed time at the end of MegaLinter run
|
||||
PARALLEL: true
|
||||
@@ -14,22 +13,16 @@ JAVASCRIPT_DEFAULT_STYLE: prettier # Default style for JavaScript
|
||||
PRINT_ALPACA: false # Print Alpaca logo in console
|
||||
SARIF_REPORTER: true # Generate SARIF report
|
||||
SHOW_SKIPPED_LINTERS: false # Show skipped linters in MegaLinter log
|
||||
|
||||
DISABLE_LINTERS:
|
||||
- REPOSITORY_DEVSKIM
|
||||
|
||||
ENABLE_LINTERS:
|
||||
- YAML_YAMLLINT
|
||||
- MARKDOWN_MARKDOWNLINT
|
||||
- YAML_PRETTIER
|
||||
- JSON_PRETTIER
|
||||
- JAVASCRIPT_ES
|
||||
- TYPESCRIPT_ES
|
||||
|
||||
YAML_YAMLLINT_CONFIG_FILE: .yamllint.yml
|
||||
MARKDOWN_MARKDOWNLINT_CONFIG_FILE: .markdownlint.json
|
||||
JAVASCRIPT_ES_CONFIG_FILE: .eslintrc.json
|
||||
TYPESCRIPT_ES_CONFIG_FILE: .eslintrc.json
|
||||
|
||||
FILTER_REGEX_EXCLUDE: >
|
||||
(node_modules|\.automation/test|docs/json-schemas|\.github/workflows)
|
||||
(node_modules|\.automation/test)
|
||||
|
||||
@@ -3,7 +3,6 @@ repos:
|
||||
- repo: https://github.com/pre-commit/pre-commit-hooks
|
||||
rev: v5.0.0
|
||||
hooks:
|
||||
- id: requirements-txt-fixer
|
||||
- id: detect-private-key
|
||||
- id: trailing-whitespace
|
||||
args: [--markdown-linebreak-ext=md]
|
||||
@@ -22,19 +21,42 @@ repos:
|
||||
- id: pretty-format-json
|
||||
args: [--autofix, --no-sort-keys]
|
||||
|
||||
- repo: https://github.com/pre-commit/sync-pre-commit-deps
|
||||
rev: v0.0.3
|
||||
hooks:
|
||||
- id: sync-pre-commit-deps
|
||||
|
||||
- repo: https://github.com/tekwizely/pre-commit-golang
|
||||
rev: v1.0.0-rc.1
|
||||
hooks:
|
||||
- id: go-build-mod
|
||||
alias: build
|
||||
- id: go-mod-tidy
|
||||
alias: tidy
|
||||
- id: golangci-lint-mod
|
||||
alias: lint
|
||||
- id: go-fmt
|
||||
alias: fmt
|
||||
args: [-s, -w]
|
||||
|
||||
- repo: https://github.com/google/yamlfmt
|
||||
rev: v0.17.2
|
||||
hooks:
|
||||
- id: yamlfmt
|
||||
|
||||
- repo: https://github.com/igorshubovych/markdownlint-cli
|
||||
rev: v0.44.0
|
||||
rev: v0.45.0
|
||||
hooks:
|
||||
- id: markdownlint
|
||||
args: [-c, .markdownlint.json, --fix]
|
||||
|
||||
- repo: https://github.com/adrienverge/yamllint
|
||||
rev: v1.37.0
|
||||
rev: v1.37.1
|
||||
hooks:
|
||||
- id: yamllint
|
||||
|
||||
- repo: https://github.com/scop/pre-commit-shfmt
|
||||
rev: v3.11.0-1
|
||||
rev: v3.12.0-2
|
||||
hooks:
|
||||
- id: shfmt
|
||||
|
||||
@@ -42,22 +64,17 @@ repos:
|
||||
rev: v0.10.0
|
||||
hooks:
|
||||
- id: shellcheck
|
||||
args: ['--severity=warning']
|
||||
args: ["--severity=warning"]
|
||||
|
||||
- repo: https://github.com/rhysd/actionlint
|
||||
rev: v1.7.7
|
||||
hooks:
|
||||
- id: actionlint
|
||||
args: ['-shellcheck=']
|
||||
|
||||
- repo: https://github.com/renovatebot/pre-commit-hooks
|
||||
rev: 39.227.2
|
||||
hooks:
|
||||
- id: renovate-config-validator
|
||||
args: ["-shellcheck="]
|
||||
|
||||
- repo: https://github.com/bridgecrewio/checkov.git
|
||||
rev: '3.2.400'
|
||||
rev: "3.2.451"
|
||||
hooks:
|
||||
- id: checkov
|
||||
args:
|
||||
- '--quiet'
|
||||
- "--quiet"
|
||||
|
||||
11
.yamlfmt.yml
Normal file
11
.yamlfmt.yml
Normal file
@@ -0,0 +1,11 @@
|
||||
---
|
||||
# yamlfmt configuration file
|
||||
# Schema: https://raw.githubusercontent.com/google/yamlfmt/main/schema.json
|
||||
formatter:
|
||||
type: basic
|
||||
include_document_start: true
|
||||
gitignore_excludes: true
|
||||
retain_line_breaks_single: true
|
||||
eof_newline: true
|
||||
max_line_length: 120
|
||||
indent: 2
|
||||
@@ -1,6 +1,5 @@
|
||||
---
|
||||
extends: default
|
||||
|
||||
rules:
|
||||
line-length:
|
||||
max: 200
|
||||
|
||||
58
Justfile
Normal file
58
Justfile
Normal file
@@ -0,0 +1,58 @@
|
||||
# Project automation for 'a' CLI wrapper for age encryption
|
||||
# Set the shell to bash for compatibility
|
||||
|
||||
set shell := ["bash", "-cu"]
|
||||
|
||||
# Variables
|
||||
|
||||
BINARY := "a"
|
||||
|
||||
# Default: show help
|
||||
default:
|
||||
@just --list
|
||||
|
||||
# Format all code (Go, YAML, Markdown)
|
||||
format:
|
||||
gofmt -s -w .
|
||||
goimports -w .
|
||||
yamlfmt -c .yamlfmt.yml .
|
||||
markdownlint -c .markdownlint.json --fix '**/*.md'
|
||||
|
||||
# Lint Go code and configs
|
||||
lint:
|
||||
golangci-lint run
|
||||
yamllint -c .yamllint.yml .
|
||||
markdownlint -c .markdownlint.json '**/*.md'
|
||||
|
||||
# Run all tests
|
||||
test:
|
||||
go test -v ./...
|
||||
|
||||
# Build the binary
|
||||
build:
|
||||
go build -o {{ BINARY }} .
|
||||
|
||||
# Run GoReleaser (dry-run by default)
|
||||
release:
|
||||
goreleaser release --clean --skip-publish --snapshot
|
||||
|
||||
# Run GoReleaser for actual release (requires env vars)
|
||||
release-publish:
|
||||
goreleaser release --clean
|
||||
|
||||
# Run pre-commit hooks on all files
|
||||
precommit:
|
||||
pre-commit run --all-files
|
||||
|
||||
# Update Go modules
|
||||
tidy:
|
||||
go mod tidy
|
||||
|
||||
# Clean build artifacts
|
||||
clean:
|
||||
rm -rf {{ BINARY }} dist/ coverage* *.log
|
||||
|
||||
# Show help
|
||||
help:
|
||||
@echo "Available commands:"
|
||||
@just --list
|
||||
105
README.md
Normal file
105
README.md
Normal file
@@ -0,0 +1,105 @@
|
||||
# A CLI Wrapper for Age Encryption
|
||||
|
||||
A robust command-line interface (CLI) wrapper around the [age](https://github.com/FiloSottile/age)
|
||||
encryption tool. This utility simplifies encryption and decryption using SSH keys,
|
||||
with integrated support for fetching public keys from GitHub.
|
||||
|
||||
## Features
|
||||
|
||||
* **Secure Encryption/Decryption:** Utilize SSH and GitHub keys with `age` for strong encryption.
|
||||
* **Configuration:** Easily configurable via a YAML file.
|
||||
* **Structured Logging:** JSON-formatted logs with configurable paths.
|
||||
* **Cross-platform:** Supports Linux, macOS, and Windows.
|
||||
* **Shell Completion:** Auto-generated completion scripts for Bash, Zsh, and Fish.
|
||||
* **Robust Error Handling:** Comprehensive and clear error messaging.
|
||||
|
||||
## Installation
|
||||
|
||||
### Prerequisites
|
||||
|
||||
* Go (1.21+)
|
||||
* `age` encryption tool
|
||||
|
||||
### Build from source
|
||||
|
||||
```bash
|
||||
git clone <repository-url>
|
||||
cd <repository-directory>
|
||||
go build -o a
|
||||
```
|
||||
|
||||
### Move binary to path (optional)
|
||||
|
||||
```bash
|
||||
sudo mv a /usr/local/bin/
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
### Basic usage
|
||||
|
||||
```bash
|
||||
a [command] [flags]
|
||||
```
|
||||
|
||||
### Commands
|
||||
|
||||
* `config`: Manage application settings
|
||||
* `encrypt`: Encrypt files
|
||||
* `decrypt`: Decrypt files
|
||||
* `completion`: Generate shell completion scripts
|
||||
|
||||
### Examples
|
||||
|
||||
#### Configure the CLI
|
||||
|
||||
```bash
|
||||
a config --ssh-key ~/.ssh/id_rsa --github-user yourusername --default-recipients ~/.ssh/id_rsa.pub --cache-ttl 120
|
||||
```
|
||||
|
||||
#### Encrypt a file
|
||||
|
||||
```bash
|
||||
a encrypt -o encrypted_file.txt input.txt
|
||||
```
|
||||
|
||||
#### Decrypt a file
|
||||
|
||||
```bash
|
||||
a decrypt -o decrypted_file.txt encrypted_file.txt
|
||||
```
|
||||
|
||||
## Generate shell completions
|
||||
|
||||
```bash
|
||||
a completion bash > /etc/bash_completion.d/a
|
||||
```
|
||||
|
||||
## Configuration File
|
||||
|
||||
Configuration is stored at `$HOME/.config/a/config.yaml`:
|
||||
|
||||
```yaml
|
||||
ssh_key_path: "/home/user/.ssh/id_rsa"
|
||||
github_user: "yourusername"
|
||||
default_recipients:
|
||||
- "/home/user/.ssh/id_rsa.pub"
|
||||
cache_ttl_minutes: 120
|
||||
log_file_path: "/home/user/.state/a/cli.log"
|
||||
```
|
||||
|
||||
## Logging
|
||||
|
||||
Structured JSON logs are written to a configurable log file (`cli.log`). Verbosity can be adjusted with the `-v` or `--verbose` flag.
|
||||
|
||||
## Testing
|
||||
|
||||
Run unit tests with:
|
||||
|
||||
```bash
|
||||
go test ./...
|
||||
```
|
||||
|
||||
## License
|
||||
|
||||
This project is licensed under the MIT License. See the [LICENSE](LICENSE) file for details.
|
||||
100
a.go
Normal file
100
a.go
Normal file
@@ -0,0 +1,100 @@
|
||||
// a is a robust CLI wrapper for the age encryption tool using SSH/GitHub keys.
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"github.com/sirupsen/logrus"
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/ivuorinen/a/cmd"
|
||||
)
|
||||
|
||||
const version = "v0.3.0"
|
||||
|
||||
var (
|
||||
log = logrus.New()
|
||||
cfg *cmd.Config
|
||||
cfgFile string
|
||||
)
|
||||
|
||||
// initConfigPaths initializes configuration and cache directories.
|
||||
func initConfigPaths() error {
|
||||
paths, err := cmd.InitConfigPaths()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
cfgFile = paths.ConfigFile
|
||||
return nil
|
||||
}
|
||||
|
||||
// loadConfig loads configuration from the YAML file.
|
||||
func loadConfig() (*cmd.Config, error) {
|
||||
return cmd.LoadConfig(cfgFile)
|
||||
}
|
||||
|
||||
// saveConfig saves configuration to the YAML file.
|
||||
func saveConfig(cfg *cmd.Config) error {
|
||||
return cmd.SaveConfig(cfgFile, cfg)
|
||||
}
|
||||
|
||||
// setupLogging configures JSON logging to file and stdout.
|
||||
func setupLogging(verbose bool) error {
|
||||
log.SetFormatter(&logrus.JSONFormatter{})
|
||||
logFile, err := os.OpenFile(cfg.LogFilePath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o600)
|
||||
if err != nil {
|
||||
return fmt.Errorf("could not open log file: %w", err)
|
||||
}
|
||||
log.SetOutput(logFile)
|
||||
if verbose {
|
||||
log.SetLevel(logrus.DebugLevel)
|
||||
} else {
|
||||
log.SetLevel(logrus.InfoLevel)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func main() {
|
||||
var verbose bool
|
||||
|
||||
rootCmd := &cobra.Command{
|
||||
Use: "a",
|
||||
Short: "CLI wrapper for age encryption using SSH/GitHub keys",
|
||||
Version: version,
|
||||
PersistentPreRunE: func(_ *cobra.Command, _ []string) error {
|
||||
if err := initConfigPaths(); err != nil {
|
||||
return fmt.Errorf("error initializing paths: %w", err)
|
||||
}
|
||||
var err error
|
||||
cfg, err = loadConfig()
|
||||
if err != nil {
|
||||
return fmt.Errorf("error loading config: %w", err)
|
||||
}
|
||||
return setupLogging(verbose)
|
||||
},
|
||||
}
|
||||
|
||||
rootCmd.PersistentFlags().BoolVarP(
|
||||
&verbose,
|
||||
"verbose",
|
||||
"v",
|
||||
false,
|
||||
"Enable verbose output",
|
||||
)
|
||||
|
||||
// Add subcommands from cmd/*
|
||||
rootCmd.AddCommand(
|
||||
cmd.ConfigCmd(cfg, func(c any) error {
|
||||
return saveConfig(c.(*cmd.Config))
|
||||
}),
|
||||
cmd.Encrypt(cfg, log),
|
||||
cmd.Decrypt(cfg, log),
|
||||
cmd.Completion(rootCmd),
|
||||
)
|
||||
|
||||
// Execute the root command
|
||||
if err := rootCmd.Execute(); err != nil {
|
||||
log.WithError(err).Fatal("Command execution failed")
|
||||
}
|
||||
}
|
||||
259
a_test.go
Normal file
259
a_test.go
Normal file
@@ -0,0 +1,259 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/sirupsen/logrus"
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"gopkg.in/yaml.v2"
|
||||
|
||||
"github.com/ivuorinen/a/cmd"
|
||||
)
|
||||
|
||||
func TestInitConfigPaths(t *testing.T) {
|
||||
paths, err := cmd.InitConfigPaths()
|
||||
assert.NoError(t, err, "initializing config paths should not produce an error")
|
||||
|
||||
assert.DirExists(t, paths.ConfigDir, "config directory should exist")
|
||||
assert.FileExists(t, paths.ConfigFile, "config file path should exist")
|
||||
assert.DirExists(t, paths.CacheDir, "cache directory should exist")
|
||||
}
|
||||
|
||||
func TestLoadAndSaveConfig(t *testing.T) {
|
||||
tempDir := t.TempDir()
|
||||
cfgFile := filepath.Join(tempDir, "config.yaml")
|
||||
|
||||
cfg := &cmd.Config{
|
||||
SSHKeyPath: "/tmp/id_rsa",
|
||||
GitHubUser: "testuser",
|
||||
DefaultRecipients: []string{"/tmp/key.pub"},
|
||||
CacheTTLMinutes: 60,
|
||||
LogFilePath: "/tmp/test.log",
|
||||
}
|
||||
|
||||
err := cmd.SaveConfig(cfgFile, cfg)
|
||||
assert.NoError(t, err, "saving config should not produce an error")
|
||||
|
||||
loadedCfg, err := cmd.LoadConfig(cfgFile)
|
||||
assert.NoError(t, err, "loading config should not produce an error")
|
||||
assert.Equal(t, cfg, loadedCfg, "loaded config should match saved config")
|
||||
}
|
||||
|
||||
func TestDefaultLogFilePath(t *testing.T) {
|
||||
tempDir := t.TempDir()
|
||||
cfgFile := filepath.Join(tempDir, "config.yaml")
|
||||
|
||||
cfg := &cmd.Config{
|
||||
SSHKeyPath: "/tmp/id_rsa",
|
||||
GitHubUser: "testuser",
|
||||
DefaultRecipients: []string{"/tmp/key.pub"},
|
||||
CacheTTLMinutes: 60,
|
||||
}
|
||||
|
||||
data, err := yaml.Marshal(cfg)
|
||||
assert.NoError(t, err, "marshaling config should not produce an error")
|
||||
assert.NoError(t, os.WriteFile(cfgFile, data, 0o600))
|
||||
|
||||
loadedCfg, err := cmd.LoadConfig(cfgFile)
|
||||
assert.NoError(t, err, "loading config should not produce an error")
|
||||
assert.NotEmpty(t, loadedCfg.LogFilePath, "default log file path should be set")
|
||||
}
|
||||
|
||||
func TestSetupLogging(t *testing.T) {
|
||||
tempLogFile := filepath.Join(t.TempDir(), "cli.log")
|
||||
cfg := &cmd.Config{LogFilePath: tempLogFile}
|
||||
|
||||
log := logrus.New()
|
||||
log.SetFormatter(&logrus.JSONFormatter{})
|
||||
logFile, err := os.OpenFile(cfg.LogFilePath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o600)
|
||||
assert.NoError(t, err, "opening log file should not produce an error")
|
||||
log.SetOutput(logFile)
|
||||
log.SetLevel(logrus.InfoLevel)
|
||||
|
||||
log.Info("Test log entry")
|
||||
assert.FileExists(t, tempLogFile, "log file should exist after setup")
|
||||
}
|
||||
|
||||
func TestCmdConfig(t *testing.T) {
|
||||
cfg := &cmd.Config{}
|
||||
cmdObj := cmd.ConfigCmd(cfg, func(_ any) error { return nil })
|
||||
assert.NotNil(t, cmdObj, "ConfigCmd should return a non-nil cobra command")
|
||||
|
||||
flags := cmdObj.Flags()
|
||||
sshKey, _ := flags.GetString("ssh-key")
|
||||
assert.Empty(t, sshKey, "default ssh-key flag should be empty")
|
||||
}
|
||||
|
||||
func TestCmdEncryptPlaceholder(t *testing.T) {
|
||||
cfg := &cmd.Config{}
|
||||
log := logrus.New()
|
||||
cmdObj := cmd.Encrypt(cfg, log)
|
||||
assert.NotNil(t, cmdObj, "Encrypt should return a non-nil cobra command")
|
||||
}
|
||||
|
||||
func TestCmdDecryptPlaceholder(t *testing.T) {
|
||||
cfg := &cmd.Config{}
|
||||
log := logrus.New()
|
||||
cmdObj := cmd.Decrypt(cfg, log)
|
||||
assert.NotNil(t, cmdObj, "Decrypt should return a non-nil cobra command")
|
||||
}
|
||||
|
||||
func TestCmdCompletion(t *testing.T) {
|
||||
rootCmd := &cobra.Command{Use: "a"}
|
||||
cmdObj := cmd.Completion(rootCmd)
|
||||
assert.NotNil(t, cmdObj, "Completion should return a non-nil cobra command")
|
||||
}
|
||||
|
||||
// Helper to generate a temporary SSH keypair for testing
|
||||
func generateSSHKeyPair(dir string) (privKey, pubKey string, err error) {
|
||||
privKey = filepath.Join(dir, "id_rsa")
|
||||
pubKey = privKey + ".pub"
|
||||
cmd := exec.Command("ssh-keygen", "-t", "rsa", "-b", "2048", "-N", "", "-f", privKey)
|
||||
if err := cmd.Run(); err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
return privKey, pubKey, nil
|
||||
}
|
||||
|
||||
// Helper to write test results to a file
|
||||
func writeTestResult(dir, name string, content []byte) {
|
||||
_ = os.WriteFile(filepath.Join(dir, name), content, 0o600)
|
||||
}
|
||||
|
||||
func TestEncryptDecrypt_Success(t *testing.T) {
|
||||
tempDir := t.TempDir()
|
||||
plaintext := []byte("This is a secret message for encryption test.")
|
||||
|
||||
// Generate SSH keypair
|
||||
privKey, pubKey, err := generateSSHKeyPair(tempDir)
|
||||
writeTestResult(
|
||||
tempDir,
|
||||
"sshkeygen_success.txt",
|
||||
fmt.Appendf(nil, "priv: %s\npub: %s\nerr: %v", privKey, pubKey, err),
|
||||
)
|
||||
assert.NoError(t, err, "ssh-keygen should succeed")
|
||||
|
||||
// Write plaintext file
|
||||
inputFile := filepath.Join(tempDir, "input.txt")
|
||||
assert.NoError(t, os.WriteFile(inputFile, plaintext, 0o600))
|
||||
|
||||
// Prepare config
|
||||
cfg := &cmd.Config{
|
||||
DefaultRecipients: []string{pubKey},
|
||||
LogFilePath: filepath.Join(tempDir, "cli.log"),
|
||||
}
|
||||
log := logrus.New()
|
||||
|
||||
// Encrypt
|
||||
encryptedFile := filepath.Join(tempDir, "encrypted.txt")
|
||||
encryptCmd := cmd.Encrypt(cfg, log)
|
||||
err = encryptCmd.Flags().Set("input", inputFile)
|
||||
assert.NoError(t, err)
|
||||
err = encryptCmd.Flags().Set("output", encryptedFile)
|
||||
assert.NoError(t, err)
|
||||
err = encryptCmd.RunE(encryptCmd, []string{})
|
||||
writeTestResult(tempDir, "encrypt_result.txt", fmt.Appendf(nil, "err: %v", err))
|
||||
assert.NoError(t, err)
|
||||
assert.FileExists(t, encryptedFile, "encrypted file should exist")
|
||||
|
||||
// Decrypt
|
||||
decryptCfg := &cmd.Config{SSHKeyPath: privKey, LogFilePath: cfg.LogFilePath}
|
||||
decryptedFile := filepath.Join(tempDir, "decrypted.txt")
|
||||
decryptCmd := cmd.Decrypt(decryptCfg, log)
|
||||
err = decryptCmd.Flags().Set("input", encryptedFile)
|
||||
assert.NoError(t, err)
|
||||
err = decryptCmd.Flags().Set("output", decryptedFile)
|
||||
assert.NoError(t, err)
|
||||
err = decryptCmd.RunE(decryptCmd, []string{})
|
||||
writeTestResult(tempDir, "decrypt_result.txt", fmt.Appendf(nil, "err: %v", err))
|
||||
assert.NoError(t, err)
|
||||
assert.FileExists(t, decryptedFile, "decrypted file should exist")
|
||||
|
||||
// Compare output (decryptedFile is generated by the test and not user-controlled)
|
||||
// Ensure decryptedFile exists and is in tempDir before reading (gosec G304 mitigation)
|
||||
info, statErr := os.Stat(decryptedFile)
|
||||
assert.NoError(t, statErr, "decrypted file should exist before reading")
|
||||
assert.True(t, strings.HasPrefix(decryptedFile, tempDir), "decrypted file must be in tempDir")
|
||||
assert.Equal(t, info.Mode().Perm(), os.FileMode(0o600), "decrypted file must have 0600 permissions")
|
||||
|
||||
// #nosec G304 -- decryptedFile is generated in tempDir and not user-controlled
|
||||
decrypted, err := os.ReadFile(decryptedFile)
|
||||
writeTestResult(tempDir, "decrypted.txt", decrypted)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, plaintext, decrypted, "decrypted output should match original plaintext")
|
||||
}
|
||||
|
||||
func TestEncryptDecrypt_WrongKey(t *testing.T) {
|
||||
tempDir := t.TempDir()
|
||||
plaintext := []byte("Secret message for wrong key test.")
|
||||
|
||||
// Generate two SSH keypairs
|
||||
_, pubKey1, err := generateSSHKeyPair(tempDir)
|
||||
assert.NoError(t, err)
|
||||
privKey2, _, err := generateSSHKeyPair(tempDir)
|
||||
assert.NoError(t, err)
|
||||
|
||||
// Write plaintext file
|
||||
inputFile := filepath.Join(tempDir, "input.txt")
|
||||
assert.NoError(t, os.WriteFile(inputFile, plaintext, 0o600))
|
||||
|
||||
// Encrypt with pubKey1
|
||||
cfg := &cmd.Config{
|
||||
DefaultRecipients: []string{pubKey1},
|
||||
LogFilePath: filepath.Join(tempDir, "cli.log"),
|
||||
}
|
||||
log := logrus.New()
|
||||
encryptedFile := filepath.Join(tempDir, "encrypted.txt")
|
||||
encryptCmd := cmd.Encrypt(cfg, log)
|
||||
err = encryptCmd.Flags().Set("input", inputFile)
|
||||
assert.NoError(t, err)
|
||||
err = encryptCmd.Flags().Set("output", encryptedFile)
|
||||
assert.NoError(t, err)
|
||||
err = encryptCmd.RunE(encryptCmd, []string{})
|
||||
writeTestResult(tempDir, "encrypt_wrongkey_result.txt", fmt.Appendf(nil, "err: %v", err))
|
||||
assert.NoError(t, err)
|
||||
assert.FileExists(t, encryptedFile, "encrypted file should exist")
|
||||
|
||||
// Try to decrypt with privKey2 (should fail)
|
||||
decryptCfg := &cmd.Config{SSHKeyPath: privKey2, LogFilePath: cfg.LogFilePath}
|
||||
decryptedFile := filepath.Join(tempDir, "decrypted_wrongkey.txt")
|
||||
decryptCmd := cmd.Decrypt(decryptCfg, log)
|
||||
err = decryptCmd.Flags().Set("input", encryptedFile)
|
||||
assert.NoError(t, err)
|
||||
err = decryptCmd.Flags().Set("output", decryptedFile)
|
||||
assert.NoError(t, err)
|
||||
err = decryptCmd.RunE(decryptCmd, []string{})
|
||||
writeTestResult(tempDir, "decrypt_wrongkey_result.txt", fmt.Appendf(nil, "err: %v", err))
|
||||
assert.Error(t, err, "decryption should fail with wrong key")
|
||||
}
|
||||
|
||||
func TestEncryptDecrypt_MissingRecipient(t *testing.T) {
|
||||
tempDir := t.TempDir()
|
||||
plaintext := []byte("Secret message for missing recipient test.")
|
||||
|
||||
// Write plaintext file
|
||||
inputFile := filepath.Join(tempDir, "input.txt")
|
||||
assert.NoError(t, os.WriteFile(inputFile, plaintext, 0o600))
|
||||
|
||||
// Encrypt with no recipient
|
||||
cfg := &cmd.Config{
|
||||
DefaultRecipients: []string{},
|
||||
LogFilePath: filepath.Join(tempDir, "cli.log"),
|
||||
}
|
||||
log := logrus.New()
|
||||
encryptedFile := filepath.Join(tempDir, "encrypted.txt")
|
||||
encryptCmd := cmd.Encrypt(cfg, log)
|
||||
err := encryptCmd.Flags().Set("input", inputFile)
|
||||
assert.NoError(t, err)
|
||||
err = encryptCmd.Flags().Set("output", encryptedFile)
|
||||
assert.NoError(t, err)
|
||||
err = encryptCmd.RunE(encryptCmd, []string{})
|
||||
writeTestResult(tempDir, "encrypt_missingrecipient_result.txt", fmt.Appendf(nil, "err: %v", err))
|
||||
assert.Error(t, err, "encryption should fail with no recipient")
|
||||
}
|
||||
27
cmd/completion.go
Normal file
27
cmd/completion.go
Normal file
@@ -0,0 +1,27 @@
|
||||
// Package cmd provides CLI command constructors for the age wrapper.
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"os"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// Completion returns a cobra.Command that generates shell completions.
|
||||
func Completion(rootCmd *cobra.Command) *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "completion [bash|zsh|fish]",
|
||||
Short: "Generate shell completion scripts",
|
||||
Args: cobra.ExactArgs(1),
|
||||
Run: func(_ *cobra.Command, args []string) {
|
||||
switch args[0] {
|
||||
case "bash":
|
||||
_ = rootCmd.GenBashCompletion(os.Stdout)
|
||||
case "zsh":
|
||||
_ = rootCmd.GenZshCompletion(os.Stdout)
|
||||
case "fish":
|
||||
_ = rootCmd.GenFishCompletion(os.Stdout, true)
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
50
cmd/config.go
Normal file
50
cmd/config.go
Normal file
@@ -0,0 +1,50 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// ConfigCmd returns a cobra.Command for configuring SSH keys, GitHub settings, and logging.
|
||||
//
|
||||
// The saveConfig callback is called with the updated config.
|
||||
func ConfigCmd(cfg any, saveConfig func(cfg any) error) *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "config",
|
||||
Short: "Configure SSH keys, GitHub settings, and logging",
|
||||
RunE: func(cmd *cobra.Command, _ []string) error {
|
||||
// Type assertion for expected config struct
|
||||
config, ok := cfg.(*Config)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
sshKey, _ := cmd.Flags().GetString("ssh-key")
|
||||
ghUser, _ := cmd.Flags().GetString("github-user")
|
||||
logPath, _ := cmd.Flags().GetString("log-file-path")
|
||||
recipients, _ := cmd.Flags().GetStringSlice("default-recipients")
|
||||
ttl, _ := cmd.Flags().GetInt("cache-ttl")
|
||||
config.SSHKeyPath = sshKey
|
||||
config.GitHubUser = ghUser
|
||||
config.DefaultRecipients = recipients
|
||||
config.CacheTTLMinutes = ttl
|
||||
config.LogFilePath = logPath
|
||||
return saveConfig(config)
|
||||
},
|
||||
}
|
||||
|
||||
// These flag defaults assume cfg is already loaded
|
||||
if config, ok := cfg.(*Config); ok {
|
||||
cmd.Flags().String("ssh-key", "", "Path to private SSH key")
|
||||
cmd.Flags().String("github-user", "", "GitHub username for public keys")
|
||||
cmd.Flags().String("log-file-path", config.LogFilePath, "Path for the log file")
|
||||
cmd.Flags().StringSlice("default-recipients", []string{}, "Public key file paths")
|
||||
cmd.Flags().Int("cache-ttl", 120, "Cache TTL in minutes")
|
||||
} else {
|
||||
cmd.Flags().String("ssh-key", "", "Path to private SSH key")
|
||||
cmd.Flags().String("github-user", "", "GitHub username for public keys")
|
||||
cmd.Flags().String("log-file-path", "", "Path for the log file")
|
||||
cmd.Flags().StringSlice("default-recipients", []string{}, "Public key file paths")
|
||||
cmd.Flags().Int("cache-ttl", 120, "Cache TTL in minutes")
|
||||
}
|
||||
|
||||
return cmd
|
||||
}
|
||||
143
cmd/config_shared.go
Normal file
143
cmd/config_shared.go
Normal file
@@ -0,0 +1,143 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
|
||||
"gopkg.in/yaml.v2"
|
||||
)
|
||||
|
||||
// Config represents the application's YAML configuration.
|
||||
type Config struct {
|
||||
SSHKeyPath string `yaml:"ssh_key_path"`
|
||||
GitHubUser string `yaml:"github_user"`
|
||||
DefaultRecipients []string `yaml:"default_recipients"`
|
||||
CacheTTLMinutes int `yaml:"cache_ttl_minutes"`
|
||||
LogFilePath string `yaml:"log_file_path"`
|
||||
}
|
||||
|
||||
// ConfigPaths holds config and cache file paths.
|
||||
type ConfigPaths struct {
|
||||
ConfigDir string
|
||||
ConfigFile string
|
||||
CacheDir string
|
||||
}
|
||||
|
||||
// InitConfigPaths initializes configuration and cache directories and returns their paths.
|
||||
func InitConfigPaths() (ConfigPaths, error) {
|
||||
var configDir string
|
||||
var err error
|
||||
|
||||
// Personal preference, I don't like the "$HOME/Library/Application Support/" path
|
||||
if runtime.GOOS == "darwin" {
|
||||
configDir = filepath.Join(os.Getenv("HOME"), ".config")
|
||||
} else {
|
||||
configDir, err = os.UserConfigDir()
|
||||
if err != nil {
|
||||
return ConfigPaths{}, err
|
||||
}
|
||||
}
|
||||
|
||||
cfgDir := filepath.Join(configDir, "a")
|
||||
cfgFile := filepath.Join(cfgDir, "config.yaml")
|
||||
if err := os.MkdirAll(cfgDir, 0o700); err != nil {
|
||||
return ConfigPaths{}, err
|
||||
}
|
||||
|
||||
cacheBase, err := os.UserCacheDir()
|
||||
if err != nil {
|
||||
return ConfigPaths{}, err
|
||||
}
|
||||
cacheDir := filepath.Join(cacheBase, "a")
|
||||
if err := os.MkdirAll(cacheDir, 0o700); err != nil {
|
||||
return ConfigPaths{}, err
|
||||
}
|
||||
|
||||
return ConfigPaths{
|
||||
ConfigDir: cfgDir,
|
||||
ConfigFile: cfgFile,
|
||||
CacheDir: cacheDir,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// LoadConfig loads configuration from the YAML file.
|
||||
// gosec G304: cfgFile is always set by InitConfigPaths and not user-controlled.
|
||||
func LoadConfig(cfgFile string) (*Config, error) {
|
||||
// gosec G304 mitigation: Ensure cfgFile is within the expected config directory
|
||||
configDir, err := os.UserConfigDir()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
expectedDir := filepath.Join(configDir, "a")
|
||||
absCfgFile, err := filepath.Abs(cfgFile)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !strings.HasPrefix(absCfgFile, expectedDir) {
|
||||
return nil, fmt.Errorf(
|
||||
"config file path %s is not within expected config directory %s",
|
||||
absCfgFile,
|
||||
expectedDir,
|
||||
)
|
||||
}
|
||||
if _, err := os.Stat(cfgFile); err != nil {
|
||||
return nil, fmt.Errorf("config file does not exist: %w", err)
|
||||
}
|
||||
|
||||
info, err := os.Stat(cfgFile)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("config file does not exist: %w", err)
|
||||
}
|
||||
if info.Mode().Perm() != 0o600 {
|
||||
return nil, fmt.Errorf("config file must have 0600 permissions, got %o", info.Mode().Perm())
|
||||
}
|
||||
// #nosec G304 -- cfgFile is validated to be within the config directory
|
||||
data, err := os.ReadFile(cfgFile)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var cfg Config
|
||||
if err := yaml.Unmarshal(data, &cfg); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if cfg.LogFilePath == "" {
|
||||
stateDir := filepath.Join(os.Getenv("HOME"), ".state", "a")
|
||||
if err := os.MkdirAll(stateDir, 0o700); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
cfg.LogFilePath = filepath.Join(stateDir, "cli.log")
|
||||
}
|
||||
return &cfg, nil
|
||||
}
|
||||
|
||||
// SaveConfig saves configuration to the YAML file.
|
||||
func SaveConfig(cfgFile string, cfg *Config) error {
|
||||
data, err := yaml.Marshal(cfg)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return os.WriteFile(cfgFile, data, 0o600)
|
||||
}
|
||||
|
||||
// ScanSSHPrivateKeys scans ~/.ssh for private keys matching id_* (excluding .pub).
|
||||
func ScanSSHPrivateKeys() ([]string, error) {
|
||||
sshDir := filepath.Join(os.Getenv("HOME"), ".ssh")
|
||||
files, err := os.ReadDir(sshDir)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var keys []string
|
||||
for _, f := range files {
|
||||
if f.IsDir() {
|
||||
continue
|
||||
}
|
||||
name := f.Name()
|
||||
if strings.HasPrefix(name, "id_") && !strings.HasSuffix(name, ".pub") {
|
||||
keys = append(keys, filepath.Join(sshDir, name))
|
||||
}
|
||||
}
|
||||
return keys, nil
|
||||
}
|
||||
122
cmd/decrypt.go
Normal file
122
cmd/decrypt.go
Normal file
@@ -0,0 +1,122 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
|
||||
"github.com/sirupsen/logrus"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// tryDecrypt attempts to decrypt using the given key and output/input files.
|
||||
func tryDecrypt(keyPath, output, input string) error {
|
||||
ageBin := "age"
|
||||
if ageBin != "age" {
|
||||
return fmt.Errorf("invalid binary for decryption: %s", ageBin)
|
||||
}
|
||||
ageArgs := []string{"-d", "-i", keyPath, "-o", output, input}
|
||||
expectedFlags := map[string]bool{"-d": true, "-i": true, "-o": true}
|
||||
for i, arg := range ageArgs {
|
||||
if i == 0 || i == 2 || i == 4 {
|
||||
if !expectedFlags[arg] && i != 0 {
|
||||
return fmt.Errorf("unexpected flag in age arguments: %s", arg)
|
||||
}
|
||||
} else if arg == "" {
|
||||
return fmt.Errorf("invalid argument for decryption: empty string")
|
||||
}
|
||||
}
|
||||
if !strings.HasSuffix(keyPath, "id_rsa") && !strings.HasSuffix(keyPath, "id_ed25519") {
|
||||
return fmt.Errorf("invalid key file for decryption: %s", keyPath)
|
||||
}
|
||||
if !strings.HasSuffix(output, ".txt") && !strings.HasSuffix(output, ".out") {
|
||||
return fmt.Errorf("invalid output file for decryption: %s", output)
|
||||
}
|
||||
// #nosec G204 -- ageBin and ageArgs are validated above
|
||||
return exec.Command(ageBin, ageArgs...).Run()
|
||||
}
|
||||
|
||||
// selectSSHKey determines which SSH key to use based on flags and config.
|
||||
func selectSSHKey(sshKeyFlag string, cfg *Config) string {
|
||||
if sshKeyFlag != "" {
|
||||
return sshKeyFlag
|
||||
}
|
||||
return cfg.SSHKeyPath
|
||||
}
|
||||
|
||||
// tryAllKeys attempts decryption with all provided keys, returns true on success.
|
||||
func tryAllKeys(keys []string, input, output string, log *logrus.Logger, triedKeys *[]string) bool {
|
||||
for _, keyPath := range keys {
|
||||
*triedKeys = append(*triedKeys, keyPath)
|
||||
log.WithFields(logrus.Fields{
|
||||
"input": input,
|
||||
"output": output,
|
||||
"sshKey": keyPath,
|
||||
}).Info("Trying decryption with SSH key")
|
||||
err := tryDecrypt(keyPath, output, input)
|
||||
if err == nil {
|
||||
log.Info("Decryption successful")
|
||||
return true
|
||||
}
|
||||
log.WithError(err).Warnf("Decryption failed with key %s", keyPath)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// Decrypt returns a cobra.Command that decrypts files using age, scanning local SSH keys if needed.
|
||||
func Decrypt(cfg *Config, log *logrus.Logger) *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "decrypt",
|
||||
Short: "Decrypt a file",
|
||||
RunE: func(cmd *cobra.Command, _ []string) error {
|
||||
input, _ := cmd.Flags().GetString("input")
|
||||
output, _ := cmd.Flags().GetString("output")
|
||||
sshKeyFlag, _ := cmd.Flags().GetString("ssh-key")
|
||||
|
||||
if input == "" {
|
||||
return fmt.Errorf("input file is required")
|
||||
}
|
||||
if output == "" {
|
||||
return fmt.Errorf("output file is required")
|
||||
}
|
||||
if _, err := os.Stat(input); err != nil {
|
||||
return fmt.Errorf("input file does not exist: %w", err)
|
||||
}
|
||||
|
||||
sshKey := selectSSHKey(sshKeyFlag, cfg)
|
||||
var triedKeys []string
|
||||
var success bool
|
||||
|
||||
if sshKey != "" {
|
||||
triedKeys = append(triedKeys, sshKey)
|
||||
log.WithFields(logrus.Fields{
|
||||
"input": input,
|
||||
"output": output,
|
||||
"sshKey": sshKey,
|
||||
}).Info("Trying decryption with provided SSH key")
|
||||
if err := tryDecrypt(sshKey, output, input); err == nil {
|
||||
log.Info("Decryption successful")
|
||||
success = true
|
||||
} else {
|
||||
log.WithError(err).Warn("Decryption failed with provided SSH key")
|
||||
}
|
||||
} else {
|
||||
keys, err := ScanSSHPrivateKeys()
|
||||
if err != nil {
|
||||
return fmt.Errorf("could not scan ~/.ssh for private keys: %w", err)
|
||||
}
|
||||
success = tryAllKeys(keys, input, output, log, &triedKeys)
|
||||
}
|
||||
|
||||
if !success {
|
||||
return fmt.Errorf("decryption failed: none of the tried SSH keys matched\nTried keys: %v", triedKeys)
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
cmd.Flags().StringP("input", "i", "", "Input file to decrypt")
|
||||
cmd.Flags().StringP("output", "o", "", "Output file for decrypted data")
|
||||
cmd.Flags().String("ssh-key", "", "SSH private key to use for decryption")
|
||||
return cmd
|
||||
}
|
||||
172
cmd/encrypt.go
Normal file
172
cmd/encrypt.go
Normal file
@@ -0,0 +1,172 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"github.com/sirupsen/logrus"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// Encrypt returns a cobra.Command that encrypts files using age, supporting GitHub key fetching.
|
||||
func Encrypt(cfg *Config, log *logrus.Logger) *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "encrypt",
|
||||
Short: "Encrypt a file",
|
||||
RunE: func(cmd *cobra.Command, _ []string) error {
|
||||
input, _ := cmd.Flags().GetString("input")
|
||||
output, _ := cmd.Flags().GetString("output")
|
||||
recipients, _ := cmd.Flags().GetStringSlice("recipient")
|
||||
ghUserFlag, _ := cmd.Flags().GetString("github-user")
|
||||
|
||||
if input == "" {
|
||||
return fmt.Errorf("input file is required")
|
||||
}
|
||||
if output == "" {
|
||||
return fmt.Errorf("output file is required")
|
||||
}
|
||||
if _, err := os.Stat(input); err != nil {
|
||||
return fmt.Errorf("input file does not exist: %w", err)
|
||||
}
|
||||
|
||||
allRecipients, ghUser, err := collectRecipients(cfg, recipients, ghUserFlag, log)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(allRecipients) == 0 {
|
||||
return fmt.Errorf("at least one recipient is required")
|
||||
}
|
||||
|
||||
ageArgs, err := buildAgeArgs(output, input, allRecipients)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
log.WithFields(logrus.Fields{
|
||||
"input": input,
|
||||
"output": output,
|
||||
"recipients": allRecipients,
|
||||
"githubUser": ghUser,
|
||||
}).Info("Encrypting file")
|
||||
|
||||
if err := runAgeEncrypt(ageArgs, log); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
log.Info("Encryption successful")
|
||||
return nil
|
||||
},
|
||||
}
|
||||
cmd.Flags().StringP("input", "i", "", "Input file to encrypt")
|
||||
cmd.Flags().StringP("output", "o", "", "Output file for encrypted data")
|
||||
cmd.Flags().StringSliceP("recipient", "r", []string{}, "Recipient public key file or string")
|
||||
cmd.Flags().String("github-user", "", "GitHub username to fetch public keys for encryption")
|
||||
return cmd
|
||||
}
|
||||
|
||||
// Helper to collect recipients including GitHub keys
|
||||
func collectRecipients(
|
||||
cfg *Config,
|
||||
recipients []string,
|
||||
ghUserFlag string,
|
||||
log *logrus.Logger,
|
||||
) ([]string, string, error) {
|
||||
allRecipients := append([]string{}, cfg.DefaultRecipients...)
|
||||
allRecipients = append(allRecipients, recipients...)
|
||||
|
||||
ghUser := ghUserFlag
|
||||
if ghUser == "" && cfg.GitHubUser != "" {
|
||||
ghUser = cfg.GitHubUser
|
||||
}
|
||||
|
||||
if ghUser != "" {
|
||||
validUser := regexp.MustCompile(`^[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,37}[a-zA-Z0-9])?$`)
|
||||
if !validUser.MatchString(ghUser) {
|
||||
log.Warnf("Invalid GitHub username: %s", ghUser)
|
||||
} else {
|
||||
url := fmt.Sprintf("https://github.com/%s.keys", ghUser)
|
||||
if !strings.HasPrefix(url, "https://github.com/") || !strings.HasSuffix(url, ".keys") {
|
||||
log.Warnf("Refusing to fetch keys from non-GitHub URL: %s", url)
|
||||
} else {
|
||||
// #nosec G107 -- url is validated to be a GitHub keys endpoint above
|
||||
resp, err := http.Get(url)
|
||||
if err != nil {
|
||||
log.WithError(err).Warnf("Failed to fetch GitHub keys for user %s", ghUser)
|
||||
} else {
|
||||
var githubKeys []string
|
||||
if resp.StatusCode == http.StatusOK {
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
closeErr := resp.Body.Close()
|
||||
if err == nil && closeErr == nil {
|
||||
for _, line := range strings.Split(string(body), "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
if line != "" {
|
||||
githubKeys = append(githubKeys, line)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if err != nil {
|
||||
log.WithError(err).Warn("Failed to read GitHub keys response body")
|
||||
}
|
||||
if closeErr != nil {
|
||||
log.WithError(closeErr).Warn("Failed to close GitHub keys response body")
|
||||
}
|
||||
}
|
||||
} else {
|
||||
_ = resp.Body.Close()
|
||||
log.Warnf("GitHub returned status %d for user %s", resp.StatusCode, ghUser)
|
||||
}
|
||||
allRecipients = append(allRecipients, githubKeys...)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return allRecipients, ghUser, nil
|
||||
}
|
||||
|
||||
// Helper to build and validate age arguments
|
||||
func buildAgeArgs(output, input string, recipients []string) ([]string, error) {
|
||||
ageArgs := []string{"-o", output}
|
||||
for _, r := range recipients {
|
||||
ageArgs = append(ageArgs, "-r", r)
|
||||
}
|
||||
ageArgs = append(ageArgs, input)
|
||||
|
||||
// Only allow expected flags for age and restrict file extensions
|
||||
expectedFlags := map[string]bool{"-o": true, "-r": true}
|
||||
for i, arg := range ageArgs {
|
||||
if i%2 == 0 && i < len(ageArgs)-2 { // flags before last two args
|
||||
if !expectedFlags[arg] {
|
||||
return nil, fmt.Errorf("unexpected flag in age arguments: %s", arg)
|
||||
}
|
||||
} else if arg == "" {
|
||||
return nil, fmt.Errorf("invalid argument for encryption: empty string")
|
||||
}
|
||||
}
|
||||
// Restrict output to expected file extensions
|
||||
if !strings.HasSuffix(output, ".txt") && !strings.HasSuffix(output, ".out") {
|
||||
return nil, fmt.Errorf("invalid output file for encryption: %s", output)
|
||||
}
|
||||
return ageArgs, nil
|
||||
}
|
||||
|
||||
// Helper to run age encryption command
|
||||
func runAgeEncrypt(ageArgs []string, log *logrus.Logger) error {
|
||||
ageBin := "age"
|
||||
if ageBin != "age" {
|
||||
return fmt.Errorf("invalid binary for encryption: %s", ageBin)
|
||||
}
|
||||
cmdAge := exec.Command(ageBin, ageArgs...)
|
||||
if err := cmdAge.Run(); err != nil {
|
||||
log.WithError(err).Error("Encryption failed")
|
||||
return fmt.Errorf("age encryption failed: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Config struct should be imported from the main package or shared as needed.
|
||||
19
go.mod
Normal file
19
go.mod
Normal file
@@ -0,0 +1,19 @@
|
||||
module github.com/ivuorinen/a
|
||||
|
||||
go 1.24
|
||||
|
||||
require (
|
||||
github.com/sirupsen/logrus v1.9.3
|
||||
github.com/spf13/cobra v1.9.1
|
||||
github.com/stretchr/testify v1.10.0
|
||||
gopkg.in/yaml.v2 v2.4.0
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
||||
github.com/pmezard/go-difflib v1.0.0 // indirect
|
||||
github.com/spf13/pflag v1.0.6 // indirect
|
||||
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
)
|
||||
28
go.sum
Normal file
28
go.sum
Normal file
@@ -0,0 +1,28 @@
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
|
||||
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/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
|
||||
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
|
||||
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/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
||||
github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
|
||||
github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
|
||||
github.com/spf13/cobra v1.9.1 h1:CXSaggrXdbHK9CF+8ywj8Amf7PBRmPCOJugH954Nnlo=
|
||||
github.com/spf13/cobra v1.9.1/go.mod h1:nDyEzZ8ogv936Cinf6g1RU9MRY64Ir93oCnqb9wxYW0=
|
||||
github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o=
|
||||
github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
|
||||
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8 h1:0A+M6Uqn+Eje4kHMK80dtF3JCXC4ykBgQG4Fe06QRhQ=
|
||||
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
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=
|
||||
72
revive.toml
Normal file
72
revive.toml
Normal file
@@ -0,0 +1,72 @@
|
||||
# Revive configuration for "a" project
|
||||
# https://revive.run/
|
||||
|
||||
ignoreGeneratedHeader = false
|
||||
severity = "warning"
|
||||
confidence = 0.8
|
||||
errorCode = 0
|
||||
warningCode = 0
|
||||
|
||||
# Core rules that align with golangci-lint settings
|
||||
[rule.blank-imports]
|
||||
[rule.context-as-argument]
|
||||
[rule.context-keys-type]
|
||||
[rule.dot-imports]
|
||||
[rule.error-return]
|
||||
[rule.error-strings]
|
||||
[rule.error-naming]
|
||||
[rule.exported]
|
||||
[rule.if-return]
|
||||
[rule.increment-decrement]
|
||||
[rule.var-naming]
|
||||
[rule.var-declaration]
|
||||
[rule.range]
|
||||
[rule.receiver-naming]
|
||||
[rule.time-naming]
|
||||
[rule.unexported-return]
|
||||
[rule.indent-error-flow]
|
||||
[rule.errorf]
|
||||
[rule.empty-block]
|
||||
[rule.superfluous-else]
|
||||
[rule.unreachable-code]
|
||||
[rule.redefines-builtin-id]
|
||||
|
||||
# Rules that complement golangci-lint but don't conflict
|
||||
[rule.atomic]
|
||||
[rule.bool-literal-in-expr]
|
||||
[rule.constant-logical-expr]
|
||||
[rule.defer]
|
||||
[rule.early-return]
|
||||
[rule.empty-lines]
|
||||
[rule.get-return]
|
||||
[rule.identical-branches]
|
||||
[rule.imports-blacklist]
|
||||
[rule.modifies-parameter]
|
||||
[rule.modifies-value-receiver]
|
||||
[rule.optimize-operands-order]
|
||||
[rule.string-of-int]
|
||||
[rule.struct-tag]
|
||||
# [rule.switch-default] # Rule not available in current version
|
||||
[rule.unconditional-recursion]
|
||||
[rule.unnecessary-stmt]
|
||||
[rule.useless-break]
|
||||
[rule.waitgroup-by-value]
|
||||
|
||||
# Project-specific rules
|
||||
[rule.package-comments] # Require package comments
|
||||
[rule.confusing-naming] # Catch confusing variable/function names
|
||||
|
||||
# Disable rules that conflict with golangci-lint or project preferences
|
||||
# [rule.unused-parameter] # Disabled - conflicts with golangci-lint unused linter
|
||||
# [rule.line-length-limit] # Disabled - conflicts with golangci-lint lll (which is also disabled)
|
||||
# [rule.function-length] # Disabled - conflicts with golangci-lint funlen (which is disabled)
|
||||
# [rule.cyclomatic] # Disabled - conflicts with golangci-lint gocyclo
|
||||
# [rule.cognitive-complexity] # Disabled - conflicts with golangci-lint gocognit (which is disabled)
|
||||
# [rule.max-public-structs] # Disabled - too restrictive for this project
|
||||
# [rule.flag-parameter] # Disabled - can be too strict for CLI applications
|
||||
# [rule.deep-exit] # Disabled - acceptable in CLI applications
|
||||
# [rule.file-header] # Disabled - no specific file header requirement
|
||||
# [rule.add-constant] # Disabled - can be too strict for configuration values
|
||||
# [rule.argument-limit] # Disabled - can be too restrictive for some functions
|
||||
# [rule.function-result-limit] # Disabled - Go allows multiple returns
|
||||
# [rule.unhandled-error] # Disabled - conflicts with golangci-lint errcheck
|
||||
Reference in New Issue
Block a user