from origin repo gh:ddworken/hishtory, commit 480630e9181167b51554f4407db55717d9b7e4dd
This commit is contained in:
2
.dockerignore
Normal file
2
.dockerignore
Normal file
@@ -0,0 +1,2 @@
|
||||
.git
|
||||
node_modules/
|
6
.errcheck_excludes.txt
Normal file
6
.errcheck_excludes.txt
Normal file
@@ -0,0 +1,6 @@
|
||||
(net/http.ResponseWriter).Write
|
||||
(*gorm.io/gorm.DB).AutoMigrate
|
||||
os.Setenv
|
||||
(*os.File).Close
|
||||
(io.ReadCloser).Close
|
||||
(*github.com/gofrs/flock.Flock).Unlock
|
13
.github/push_event.json
vendored
Normal file
13
.github/push_event.json
vendored
Normal file
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"push": {
|
||||
"head": {
|
||||
"ref": "users/foo/update-action"
|
||||
},
|
||||
"base": {
|
||||
"ref": "users/foo/update-action"
|
||||
}
|
||||
},
|
||||
"head_commit": {
|
||||
"message": "build latest"
|
||||
}
|
||||
}
|
15
.github/slsa/.slsa-goreleaser-darwin-amd64.yml
vendored
Normal file
15
.github/slsa/.slsa-goreleaser-darwin-amd64.yml
vendored
Normal file
@@ -0,0 +1,15 @@
|
||||
version: 1
|
||||
|
||||
env:
|
||||
- CGO_ENABLED=0
|
||||
|
||||
flags:
|
||||
- -trimpath
|
||||
|
||||
goos: darwin
|
||||
goarch: amd64
|
||||
|
||||
binary: hishtory-{{ .Os }}-{{ .Arch }}
|
||||
|
||||
ldflags:
|
||||
- '{{ .Env.VERSION_LDFLAGS }}'
|
15
.github/slsa/.slsa-goreleaser-darwin-arm64.yml
vendored
Normal file
15
.github/slsa/.slsa-goreleaser-darwin-arm64.yml
vendored
Normal file
@@ -0,0 +1,15 @@
|
||||
version: 1
|
||||
|
||||
env:
|
||||
- CGO_ENABLED=0
|
||||
|
||||
flags:
|
||||
- -trimpath
|
||||
|
||||
goos: darwin
|
||||
goarch: arm64
|
||||
|
||||
binary: hishtory-{{ .Os }}-{{ .Arch }}
|
||||
|
||||
ldflags:
|
||||
- '{{ .Env.VERSION_LDFLAGS }}'
|
15
.github/slsa/.slsa-goreleaser-freebsd-amd64.yml
vendored
Normal file
15
.github/slsa/.slsa-goreleaser-freebsd-amd64.yml
vendored
Normal file
@@ -0,0 +1,15 @@
|
||||
version: 1
|
||||
|
||||
env:
|
||||
- CGO_ENABLED=0
|
||||
|
||||
flags:
|
||||
- -trimpath
|
||||
|
||||
goos: freebsd
|
||||
goarch: amd64
|
||||
|
||||
binary: hishtory-{{ .Os }}-{{ .Arch }}
|
||||
|
||||
ldflags:
|
||||
- '{{ .Env.VERSION_LDFLAGS }}'
|
15
.github/slsa/.slsa-goreleaser-linux-amd64.yml
vendored
Normal file
15
.github/slsa/.slsa-goreleaser-linux-amd64.yml
vendored
Normal file
@@ -0,0 +1,15 @@
|
||||
version: 1
|
||||
|
||||
env:
|
||||
- CGO_ENABLED=0
|
||||
|
||||
flags:
|
||||
- -trimpath
|
||||
|
||||
goos: linux
|
||||
goarch: amd64
|
||||
|
||||
binary: hishtory-{{ .Os }}-{{ .Arch }}
|
||||
|
||||
ldflags:
|
||||
- '{{ .Env.VERSION_LDFLAGS }}'
|
37
.github/workflows/codeql-analysis.yml
vendored
Normal file
37
.github/workflows/codeql-analysis.yml
vendored
Normal file
@@ -0,0 +1,37 @@
|
||||
name: "CodeQL"
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ master ]
|
||||
pull_request:
|
||||
# The branches below must be a subset of the branches above
|
||||
branches: [ master ]
|
||||
schedule:
|
||||
- cron: '16 13 * * 4'
|
||||
|
||||
jobs:
|
||||
analyze:
|
||||
name: Analyze
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
actions: read
|
||||
contents: read
|
||||
security-events: write
|
||||
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
language: [ 'go' ]
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v3
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@v2
|
||||
with:
|
||||
languages: ${{ matrix.language }}
|
||||
- name: Autobuild
|
||||
uses: github/codeql-action/autobuild@v2
|
||||
- name: Perform CodeQL Analysis
|
||||
if: ${{ !startsWith(github.event.head_commit.message, 'Release') }}
|
||||
uses: github/codeql-action/analyze@v2
|
34
.github/workflows/docker-compose-test.yml
vendored
Normal file
34
.github/workflows/docker-compose-test.yml
vendored
Normal file
@@ -0,0 +1,34 @@
|
||||
name: Docker Compose Tests
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
pull_request:
|
||||
push:
|
||||
branches: [ master ]
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v3
|
||||
with:
|
||||
go-version: 1.18
|
||||
# - uses: mxschmitt/action-tmate@v3
|
||||
- name: Docker Compose test
|
||||
if: ${{ !startsWith(github.event.head_commit.message, 'Release') }}
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y zsh fish
|
||||
curl -fsSL https://get.docker.com | sudo sh
|
||||
sudo chmod 0755 -R /usr/share/zsh/ || true # Work around a weird bug where zsh on ubuntu actions gives that diretory 0777 which makes zsh refuse to start
|
||||
sudo hostname ghaction-runner-hostname # Set a consistent hostname so we can run tests that depend on it
|
||||
docker compose -f backend/server/docker-compose.yml build
|
||||
docker compose -f backend/server/docker-compose.yml up -d
|
||||
export HISHTORY_SERVER=http://localhost
|
||||
go build
|
||||
./hishtory install
|
||||
source ~/.bashrc
|
||||
ls
|
||||
./hishtory query
|
39
.github/workflows/go-test.yml
vendored
Normal file
39
.github/workflows/go-test.yml
vendored
Normal file
@@ -0,0 +1,39 @@
|
||||
name: Go Tests
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
pull_request:
|
||||
schedule:
|
||||
- cron: '0 0 * * *'
|
||||
push:
|
||||
branches: [ master ]
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ${{ matrix.os }}
|
||||
strategy:
|
||||
matrix:
|
||||
os: [ubuntu-latest, macos-latest]
|
||||
fail-fast: false
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v3
|
||||
with:
|
||||
go-version: 1.18
|
||||
- name: Go test
|
||||
if: ${{ !startsWith(github.event.head_commit.message, 'Release') }}
|
||||
run: |
|
||||
sudo apt-get update || true
|
||||
sudo apt-get install -y zsh fish || true
|
||||
brew install fish tmux bash || true
|
||||
export TZ='America/Los_Angeles' # Force the time zone so that test output is consistent
|
||||
sudo chmod 0755 -R /usr/share/zsh/ || true # Work around a weird bug where zsh on ubuntu actions gives that diretory 0777 which makes zsh refuse to start
|
||||
sudo hostname ghaction-runner-hostname || true # Set a consistent hostname so we can run tests that depend on it
|
||||
sudo scutil --set HostName ghaction-runner-hostname || true
|
||||
make test
|
||||
- name: Setup tmate session
|
||||
if: ${{ failure() }}
|
||||
uses: mxschmitt/action-tmate@v3
|
||||
with:
|
||||
limit-access-to-actor: true
|
146
.github/workflows/slsa-releaser.yml
vendored
Normal file
146
.github/workflows/slsa-releaser.yml
vendored
Normal file
@@ -0,0 +1,146 @@
|
||||
name: SLSA go releaser
|
||||
on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
tags:
|
||||
- "*"
|
||||
|
||||
permissions: read-all
|
||||
|
||||
jobs:
|
||||
# ldflags to embed the commit hash in the binary
|
||||
args:
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
ldflags: ${{ steps.ldflags.outputs.value }}
|
||||
steps:
|
||||
- id: checkout
|
||||
uses: actions/checkout@ec3a7ce113134d7a93b817d10a8272cb61118579 # v2.3.4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- id: ldflags
|
||||
run: |
|
||||
echo "::set-output name=value::$(./scripts/client-ldflags)"
|
||||
|
||||
# Trusted builders
|
||||
build-linux-amd64:
|
||||
permissions:
|
||||
id-token: write
|
||||
contents: write
|
||||
actions: read
|
||||
needs: args
|
||||
uses: slsa-framework/slsa-github-generator/.github/workflows/builder_go_slsa3.yml@v1.2.1
|
||||
with:
|
||||
config-file: .github/slsa/.slsa-goreleaser-linux-amd64.yml
|
||||
go-version: 1.18
|
||||
evaluated-envs: "VERSION_LDFLAGS:${{needs.args.outputs.ldflags}}"
|
||||
compile-builder: true # See github.com/slsa-framework/slsa-github-generator/issues/942
|
||||
build-freebsd-amd64:
|
||||
permissions:
|
||||
id-token: write
|
||||
contents: write
|
||||
actions: read
|
||||
needs: args
|
||||
uses: slsa-framework/slsa-github-generator/.github/workflows/builder_go_slsa3.yml@v1.2.1
|
||||
with:
|
||||
config-file: .github/slsa/.slsa-goreleaser-freebsd-amd64.yml
|
||||
go-version: 1.18
|
||||
evaluated-envs: "VERSION_LDFLAGS:${{needs.args.outputs.ldflags}}"
|
||||
compile-builder: true # See github.com/slsa-framework/slsa-github-generator/issues/942
|
||||
build-darwin-amd64:
|
||||
permissions:
|
||||
id-token: write
|
||||
contents: write
|
||||
actions: read
|
||||
needs:
|
||||
- args
|
||||
uses: slsa-framework/slsa-github-generator/.github/workflows/builder_go_slsa3.yml@v1.2.1
|
||||
with:
|
||||
config-file: .github/slsa/.slsa-goreleaser-darwin-amd64.yml
|
||||
go-version: 1.18
|
||||
evaluated-envs: "VERSION_LDFLAGS:${{needs.args.outputs.ldflags}}"
|
||||
compile-builder: true # See github.com/slsa-framework/slsa-github-generator/issues/942
|
||||
build-darwin-arm64:
|
||||
permissions:
|
||||
id-token: write
|
||||
contents: write
|
||||
actions: read
|
||||
needs:
|
||||
- args
|
||||
uses: slsa-framework/slsa-github-generator/.github/workflows/builder_go_slsa3.yml@v1.2.1
|
||||
with:
|
||||
config-file: .github/slsa/.slsa-goreleaser-darwin-arm64.yml
|
||||
go-version: 1.18
|
||||
evaluated-envs: "VERSION_LDFLAGS:${{needs.args.outputs.ldflags}}"
|
||||
compile-builder: true # See github.com/slsa-framework/slsa-github-generator/issues/942
|
||||
|
||||
# Sign the binaries and upload the signed binaries
|
||||
macos_signer:
|
||||
runs-on: macos-11.0
|
||||
needs:
|
||||
- upload
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- name: Download and sign the latest executables
|
||||
env:
|
||||
MACOS_CERTIFICATE: ${{ secrets.MACOS_CERTIFICATE }}
|
||||
MACOS_CERTIFICATE_PWD: ${{ secrets.MACOS_CERTIFICATE_PWD }}
|
||||
run: |
|
||||
export GITHUB_TOKEN="${{ secrets.GITHUB_TOKEN }}"
|
||||
pip3 install requests
|
||||
brew install md5sha1sum
|
||||
python3 scripts/actions-sign.py
|
||||
- name: Release
|
||||
uses: softprops/action-gh-release@v1
|
||||
if: startsWith(github.ref, 'refs/tags/')
|
||||
with:
|
||||
files: |
|
||||
hishtory-darwin-arm64
|
||||
hishtory-darwin-arm64-unsigned
|
||||
hishtory-darwin-amd64
|
||||
hishtory-darwin-amd64-unsigned
|
||||
- name: Trigger the backend API service so it knows a release is finished
|
||||
run: |
|
||||
curl https://api.hishtory.dev/api/v1/trigger-cron
|
||||
|
||||
# Upload to GitHub release.
|
||||
upload:
|
||||
permissions:
|
||||
contents: write
|
||||
runs-on: ubuntu-latest
|
||||
needs:
|
||||
- build-linux-amd64
|
||||
- build-darwin-amd64
|
||||
- build-darwin-arm64
|
||||
steps:
|
||||
- uses: actions/download-artifact@fb598a63ae348fa914e94cd0ff38f362e927b741
|
||||
with:
|
||||
name: hishtory-linux-amd64
|
||||
- uses: actions/download-artifact@fb598a63ae348fa914e94cd0ff38f362e927b741
|
||||
with:
|
||||
name: hishtory-linux-amd64.intoto.jsonl
|
||||
- uses: actions/download-artifact@fb598a63ae348fa914e94cd0ff38f362e927b741
|
||||
with:
|
||||
name: hishtory-darwin-amd64
|
||||
- uses: actions/download-artifact@fb598a63ae348fa914e94cd0ff38f362e927b741
|
||||
with:
|
||||
name: hishtory-darwin-amd64.intoto.jsonl
|
||||
- uses: actions/download-artifact@fb598a63ae348fa914e94cd0ff38f362e927b741
|
||||
with:
|
||||
name: hishtory-darwin-arm64
|
||||
- uses: actions/download-artifact@fb598a63ae348fa914e94cd0ff38f362e927b741
|
||||
with:
|
||||
name: hishtory-darwin-arm64.intoto.jsonl
|
||||
- name: Release
|
||||
uses: softprops/action-gh-release@1e07f4398721186383de40550babbdf2b84acfc5
|
||||
if: ${{ startsWith(github.ref, 'refs/tags/') && contains(github.ref, '-') }}
|
||||
with:
|
||||
files: |
|
||||
hishtory-linux-amd64
|
||||
hishtory-linux-amd64.intoto.jsonl
|
||||
hishtory-darwin-amd64
|
||||
hishtory-darwin-amd64.intoto.jsonl
|
||||
hishtory-darwin-arm64
|
||||
hishtory-darwin-arm64.intoto.jsonl
|
4
.gitignore
vendored
Normal file
4
.gitignore
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
web/landing/www/binaries/hishtory-linux
|
||||
hishtory
|
||||
backend/server/server
|
||||
postgres-data/
|
17
.pre-commit-config.yaml
Normal file
17
.pre-commit-config.yaml
Normal file
@@ -0,0 +1,17 @@
|
||||
repos:
|
||||
- repo: https://github.com/Bahjat/pre-commit-golang
|
||||
rev: a4be1d0f860565649a450a8d480e541844c14a07
|
||||
hooks:
|
||||
- id: go-fmt-import
|
||||
- id: go-vet
|
||||
- id: gofumpt # requires github.com/mvdan/gofumpt
|
||||
- id: go-static-check # install https://staticcheck.io/docs/
|
||||
exclude: /vndor/
|
||||
- id: golangci-lint # requires github.com/golangci/golangci-lint
|
||||
- repo: local
|
||||
hooks:
|
||||
- id: go-errcheck
|
||||
name: go-errcheck
|
||||
entry: errcheck -exclude .errcheck_excludes.txt ./...
|
||||
language: system
|
||||
pass_filenames: false
|
21
LICENSE
Normal file
21
LICENSE
Normal file
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2022 David Dworken
|
||||
|
||||
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 above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
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.
|
35
Makefile
Normal file
35
Makefile
Normal file
@@ -0,0 +1,35 @@
|
||||
forcetest:
|
||||
go clean -testcache
|
||||
HISHTORY_TEST=1 HISHTORY_SKIP_INIT_IMPORT=1 go test -p 1 -timeout 30m ./...
|
||||
|
||||
test:
|
||||
HISHTORY_TEST=1 HISHTORY_SKIP_INIT_IMPORT=1 go test -p 1 -timeout 30m ./...
|
||||
|
||||
acttest:
|
||||
act push -j test -e .github/push_event.json --reuse --container-architecture linux/amd64
|
||||
|
||||
release:
|
||||
# Bump the version
|
||||
expr `cat VERSION` + 1 > VERSION
|
||||
git add VERSION
|
||||
git commit -m "Release v0.`cat VERSION`" --no-verify
|
||||
git push
|
||||
gh release create v0.`cat VERSION` --generate-notes
|
||||
git push && git push --tags
|
||||
|
||||
build-static:
|
||||
docker build -t gcr.io/dworken-k8s/hishtory-static -f backend/web/caddy/Dockerfile .
|
||||
|
||||
build-api:
|
||||
docker build -t gcr.io/dworken-k8s/hishtory-api -f backend/server/Dockerfile .
|
||||
|
||||
deploy-static: build-static
|
||||
docker push gcr.io/dworken-k8s/hishtory-static
|
||||
ssh monoserver "cd ~/infra/ && docker compose pull hishtory-static && docker compose up -d --no-deps hishtory-static"
|
||||
|
||||
deploy-api: build-api
|
||||
docker push gcr.io/dworken-k8s/hishtory-api
|
||||
ssh monoserver "cd ~/infra/ && docker compose pull hishtory-api && docker compose up -d --no-deps hishtory-api"
|
||||
|
||||
deploy: release deploy-static deploy-api
|
||||
|
146
README.md
Normal file
146
README.md
Normal file
@@ -0,0 +1,146 @@
|
||||
# hiSHtory: Better Shell History
|
||||
|
||||
`hishtory` is a better shell history. It stores your shell history in context (what directory you ran the command in, whether it succeeded or failed, how long it took, etc). This is all stored locally and end-to-end encrypted for syncing to to all your other computers. All of this is easily queryable via the `hishtory` CLI. This means from your laptop, you can easily find that complex bash pipeline you wrote on your server, and see the context in which you ran it.
|
||||
|
||||

|
||||
|
||||
## Getting Started
|
||||
|
||||
To install `hishtory` on your first machine:
|
||||
|
||||
```bash
|
||||
curl https://hishtory.dev/install.py | python3 -
|
||||
```
|
||||
|
||||
At this point, `hishtory` is already managing your shell history (for bash, zsh, and fish!). Give it a try with `hishtory query` and see below for more details on the advanced query features.
|
||||
|
||||
Then to install `hishtory` on your other computers, you need your secret key. Get this by running `hishtory status`. Once you have it, you follow similar steps to install hiSHtory on your other computers:
|
||||
|
||||
```bash
|
||||
curl https://hishtory.dev/install.py | python3 -
|
||||
hishtory init $YOUR_HISHTORY_SECRET
|
||||
```
|
||||
|
||||
Now if you run `hishtory query` on first computer, you can automatically see the commands you've run on all your other computers!
|
||||
|
||||
## Features
|
||||
|
||||
### Querying
|
||||
|
||||
There are two ways to interact with hiSHtory.
|
||||
|
||||
1. Via pressing `Control+R` in your terminal. Search for a command, select it via `Enter`, and then have it ready to execute in your terminal's buffer.
|
||||
2. Via `hishtory query` if you just want to explore your shell history.
|
||||
|
||||
Both support the same query format, see the below annotated queries:
|
||||
|
||||
| Query | Explanation |
|
||||
|---|---|
|
||||
| `psql` | Find all commands containing `psql` |
|
||||
| `psql db.example.com` | Find all commands containing `psql` and `db.example.com` |
|
||||
| `docker hostname:my-server` | Find all commands containing `docker` that were run on the computer with hostname `my-server` |
|
||||
| `nano user:root` | Find all commands containing `nano` that were run as `root` |
|
||||
| `exit_code:127` | Find all commands that exited with code `127` |
|
||||
| `service before:2022-02-01` | Find all commands containing `service` run before February 1st 2022 |
|
||||
| `service after:2022-02-01` | Find all commands containing `service` run after February 1st 2022 |
|
||||
|
||||
For true power users, you can even query in SQLite via `sqlite3 -cmd 'PRAGMA journal_mode = WAL' ~/.hishtory/.hishtory.db`.
|
||||
|
||||
### Enable/Disable
|
||||
|
||||
If you want to temporarily turn on/off hiSHtory recording, you can do so via `hishtory disable` (to turn off recording) and `hishtory enable` (to turn on recording). You can check whether or not `hishtory` is enabled via `hishtory status`.
|
||||
|
||||
### Deletion
|
||||
|
||||
`hishtory redact` can be used to delete history entries that you didn't intend to record. It accepts the same search format as `hishtory query`. For example, to delete all history entries containing `psql`, run `hishtory redact psql`.
|
||||
|
||||
### Updating
|
||||
|
||||
To update `hishtory` to the latest version, just run `hishtory update` to securely download and apply the latest update.
|
||||
|
||||
### Advanced Features
|
||||
|
||||
<details>
|
||||
<summary>Changing the displayed columns</summary>
|
||||
|
||||
You can customize the columns that are displayed via `hishtory config-set displayed-columns`. For example, to display only the cwd and command:
|
||||
|
||||
```
|
||||
hishtory config-set displayed-columns CWD Command
|
||||
```
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>Custom Columns</summary>
|
||||
|
||||
You can create custom column definitions that are populated from arbitrary commands. For example, if you want to create a new column named `git_remote` that contains the git remote if the cwd is in a git directory, you can run:
|
||||
|
||||
```
|
||||
hishtory config-add custom-column git_remote '(git remote -v 2>/dev/null | grep origin 1>/dev/null ) && git remote get-url origin || true'
|
||||
hishtory config-add displayed-columns git_remote
|
||||
```
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>Disabling Control-R integration</summary>
|
||||
If you'd like to disable the control-R integration in your shell, you can do so by running `hishtory config-set enable-control-r false`.
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>Filtering duplicate entries</summary>
|
||||
By default, hishtory query will show all results even if this includes duplicate history entries. This helps you keep track of how many times you've run a command and in what contexts. If you'd rather disable this so that hiSHtory won't show duplicate entries, you can run:
|
||||
|
||||
```
|
||||
hishtory config-set filter-duplicate-commands true
|
||||
```
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>Offline Install</summary>
|
||||
If you don't need the ability to sync your shell history, you can install hiSHtory in offline mode.
|
||||
|
||||
Download the latest binary from [Github Releases](https://github.com/ddworken/hishtory/releases), and then run `./hishtory-binary install --offline` to install hiSHtory in a fully offline mode. This disables syncing and it is not possible to re-enable syncing after doing this.
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>Self-Hosting</summary>
|
||||
By default, hiSHtory relies on a backend for syncing. All data is end-to-end encrypted, so the backend can't view your history.
|
||||
|
||||
But if you'd like to self-host the hishtory backend, you can! The backend is a simple go binary in `backend/server/server.go` that uses postgres to store data. Check out the [`docker-compose.yml`](https://github.com/ddworken/hishtory/blob/master/backend/server/docker-compose.yml) file for an example config to start a hiSHtory server and how to configure it.
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>Importing existing history</summary>
|
||||
hiSHtory imports your existing shell history by default. If for some reason this didn't work (e.g. you had your shell history in a non-standard file), you can import it by piping it into `hishtory import` (e.g. `cat ~/.my_history | hishtory import`).
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>Custom timestamp formats</summary>
|
||||
You can configure a custom timestamp format for hiSHtory via `hishtory config-set timestamp-format '2006/Jan/2 15:04'`. The timestamp format string should be in [the format used by Go's `time.Format(...)`](https://pkg.go.dev/time#Time.Format).
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>Uninstalling</summary>
|
||||
If you'd like to uninstall hishtory, just run `hishtory uninstall`. Note that this deletes the SQLite DB storing your history, so consider running a `hishtory export` first.
|
||||
</details>
|
||||
|
||||
## Design
|
||||
|
||||
The `hishtory` CLI is written in Go. It hooks into the shell in order to track information about all commands that are run. It takes this data and saves it in a local SQLite DB managed via [GORM](https://gorm.io/). This data is then encrypted and sent to your other devices through a backend that essentially functions as a one-to-many queue. When you run `hishtory query`, a SQL query is run to find matching entries in the local SQLite DB.
|
||||
|
||||
### Syncing Design
|
||||
|
||||
See [hiSHtory: Cross-device Encrypted Syncing Design](https://blog.daviddworken.com/posts/hishtory-explained/) to learn how syncing works. The tl;dr is that everything magically works so that:
|
||||
|
||||
* The backend can't read your history.
|
||||
* Your history is queryable from all your devices.
|
||||
* You can delete items from your history as needed.
|
||||
* If you go offline, you'll have an offline copy of your history. And once you come back online, syncing will transparently resume.
|
||||
|
||||
## Security
|
||||
|
||||
`hishtory` is a CLI tool written in Go and uses AES-GCM for end-to-end encrypting your history entries while syncing them. The binary is reproducibly built and [SLSA Level 3](https://slsa.dev/) to make it easy to verify you're getting the code contained in this repository.
|
||||
|
||||
This all ensures that the minimalist backend cannot read your shell history, it only sees encrypted data.
|
||||
|
||||
If you find any security issues in hiSHtory, please reach out to `david@daviddworken.com`.
|
10
backend/server/Dockerfile
Normal file
10
backend/server/Dockerfile
Normal file
@@ -0,0 +1,10 @@
|
||||
FROM golang:1.18 AS builder
|
||||
COPY go.mod ./
|
||||
COPY go.sum ./
|
||||
RUN unset GOPATH; go mod download
|
||||
COPY . ./
|
||||
RUN unset GOPATH; GOARCH=amd64 go build -o /server -ldflags "-X main.ReleaseVersion=v0.`cat VERSION`" backend/server/server.go
|
||||
|
||||
FROM golang:1.18
|
||||
COPY --from=builder /server /server
|
||||
CMD ["/server"]
|
35
backend/server/docker-compose.yml
Normal file
35
backend/server/docker-compose.yml
Normal file
@@ -0,0 +1,35 @@
|
||||
# A docker-compose file to host a hiSHtory backend. To use:
|
||||
# 1. Update TODO_YOUR_POSTGRES_PASSWORD_HERE
|
||||
# 2. `docker compose -f backend/server/docker-compose.yml build`
|
||||
# 3. `docker compose -f backend/server/docker-compose.yml up`
|
||||
# 4. Point your hiSHtory client at the server by putting `export HISHTORY_SERVER=http://1.2.3.4` in your shellrc
|
||||
# 5. Run `hishtory init` to initialize hiSHtory with the local server
|
||||
# 6. [Optional, but recommended] Add a TLS proxy to enable https
|
||||
networks:
|
||||
hishtory:
|
||||
driver: bridge
|
||||
services:
|
||||
postgres:
|
||||
image: postgres
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
- hishtory
|
||||
environment:
|
||||
POSTGRES_PASSWORD: TODO_YOUR_POSTGRES_PASSWORD_HERE
|
||||
POSTGRES_DB: hishtory
|
||||
PGDATA: /var/lib/postgresql/data/pgdata
|
||||
volumes:
|
||||
- ./postgres-data:/var/lib/postgresql/data
|
||||
hishtory:
|
||||
depends_on:
|
||||
- postgres
|
||||
networks:
|
||||
- hishtory
|
||||
build:
|
||||
context: ../../
|
||||
dockerfile: ./backend/server/native-arch-Dockerfile
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
HISHTORY_POSTGRES_DB: postgresql://postgres:TODO_YOUR_POSTGRES_PASSWORD_HERE@postgres:5432/hishtory?sslmode=disable
|
||||
ports:
|
||||
- 80:8080
|
17
backend/server/native-arch-Dockerfile
Normal file
17
backend/server/native-arch-Dockerfile
Normal file
@@ -0,0 +1,17 @@
|
||||
# A fork of Dockerfile that doesn't hard code GOARCH and that uses wait-for to wait
|
||||
# until the postgres server is up. Meant to be used in the docker-compose file for self hosting.
|
||||
|
||||
FROM golang:1.18 AS builder
|
||||
COPY go.mod ./
|
||||
COPY go.sum ./
|
||||
RUN unset GOPATH; go mod download
|
||||
COPY . ./
|
||||
RUN unset GOPATH; go build -o /server -ldflags "-X main.ReleaseVersion=v0.`cat VERSION`" backend/server/server.go
|
||||
|
||||
FROM golang:1.18
|
||||
RUN apt-get update && apt-get install -y netcat
|
||||
# Downlaod wait-for from a specific commit hash. This ensures that the owner of wait-for isn't in our TCB (though Github still is)
|
||||
RUN curl https://raw.githubusercontent.com/eficode/wait-for/59bec22851ba83e9cc735a67a7d961f8aae2cd85/wait-for > /wait-for
|
||||
RUN chmod +x /wait-for
|
||||
COPY --from=builder /server /server
|
||||
CMD ["/wait-for", "postgres:5432", "--", "/server"]
|
674
backend/server/server.go
Normal file
674
backend/server/server.go
Normal file
@@ -0,0 +1,674 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"html"
|
||||
"io/ioutil"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/user"
|
||||
"runtime"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/ddworken/hishtory/shared"
|
||||
_ "github.com/lib/pq"
|
||||
"github.com/rodaine/table"
|
||||
"gorm.io/driver/postgres"
|
||||
|
||||
"gorm.io/driver/sqlite"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
const (
|
||||
PostgresDb = "postgresql://postgres:%s@postgres:5432/hishtory?sslmode=disable"
|
||||
)
|
||||
|
||||
var (
|
||||
GLOBAL_DB *gorm.DB
|
||||
ReleaseVersion string = "UNKNOWN"
|
||||
)
|
||||
|
||||
type UsageData struct {
|
||||
UserId string `json:"user_id" gorm:"not null; uniqueIndex:usageDataUniqueIndex"`
|
||||
DeviceId string `json:"device_id" gorm:"not null; uniqueIndex:usageDataUniqueIndex"`
|
||||
LastUsed time.Time `json:"last_used"`
|
||||
LastIp string `json:"last_ip"`
|
||||
NumEntriesHandled int `json:"num_entries_handled"`
|
||||
LastQueried time.Time `json:"last_queried"`
|
||||
NumQueries int `json:"num_queries"`
|
||||
Version string `json:"version"`
|
||||
}
|
||||
|
||||
func getRequiredQueryParam(r *http.Request, queryParam string) string {
|
||||
val := r.URL.Query().Get(queryParam)
|
||||
if val == "" {
|
||||
panic(fmt.Sprintf("request to %s is missing required query param=%#v", r.URL, queryParam))
|
||||
}
|
||||
return val
|
||||
}
|
||||
|
||||
func getHishtoryVersion(r *http.Request) string {
|
||||
return r.Header.Get("X-Hishtory-Version")
|
||||
}
|
||||
|
||||
func updateUsageData(r *http.Request, userId, deviceId string, numEntriesHandled int, isQuery bool) {
|
||||
var usageData []UsageData
|
||||
GLOBAL_DB.Where("user_id = ? AND device_id = ?", userId, deviceId).Find(&usageData)
|
||||
if len(usageData) == 0 {
|
||||
GLOBAL_DB.Create(&UsageData{UserId: userId, DeviceId: deviceId, LastUsed: time.Now(), NumEntriesHandled: numEntriesHandled, Version: getHishtoryVersion(r)})
|
||||
} else {
|
||||
usage := usageData[0]
|
||||
GLOBAL_DB.Model(&UsageData{}).Where("user_id = ? AND device_id = ?", userId, deviceId).Update("last_used", time.Now()).Update("last_ip", getRemoteAddr(r))
|
||||
if numEntriesHandled > 0 {
|
||||
GLOBAL_DB.Exec("UPDATE usage_data SET num_entries_handled = COALESCE(num_entries_handled, 0) + ? WHERE user_id = ? AND device_id = ?", numEntriesHandled, userId, deviceId)
|
||||
}
|
||||
if usage.Version != getHishtoryVersion(r) {
|
||||
GLOBAL_DB.Exec("UPDATE usage_data SET version = ? WHERE user_id = ? AND device_id = ?", getHishtoryVersion(r), userId, deviceId)
|
||||
}
|
||||
}
|
||||
if isQuery {
|
||||
GLOBAL_DB.Exec("UPDATE usage_data SET num_queries = COALESCE(num_queries, 0) + 1, last_queried = ? WHERE user_id = ? AND device_id = ?", time.Now(), userId, deviceId)
|
||||
}
|
||||
}
|
||||
|
||||
func usageStatsHandler(w http.ResponseWriter, r *http.Request) {
|
||||
query := `
|
||||
SELECT
|
||||
MIN(devices.registration_date) as registration_date,
|
||||
COUNT(DISTINCT devices.device_id) as num_devices,
|
||||
SUM(usage_data.num_entries_handled) as num_history_entries,
|
||||
MAX(usage_data.last_used) as last_active,
|
||||
COALESCE(STRING_AGG(DISTINCT usage_data.last_ip, ', ') FILTER (WHERE usage_data.last_ip != 'Unknown'), 'Unknown') as ip_addresses,
|
||||
COALESCE(SUM(usage_data.num_queries), 0) as num_queries,
|
||||
COALESCE(MAX(usage_data.last_queried), 'January 1, 1970') as last_queried,
|
||||
STRING_AGG(DISTINCT usage_data.version, ', ') as versions
|
||||
FROM devices
|
||||
INNER JOIN usage_data ON devices.device_id = usage_data.device_id
|
||||
GROUP BY devices.user_id
|
||||
ORDER BY registration_date
|
||||
`
|
||||
rows, err := GLOBAL_DB.Raw(query).Rows()
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
tbl := table.New("Registration Date", "Num Devices", "Num Entries", "Num Queries", "Last Active", "Last Query", "Versions", "IPs")
|
||||
tbl.WithWriter(w)
|
||||
for rows.Next() {
|
||||
var registrationDate time.Time
|
||||
var numDevices int
|
||||
var numEntries int
|
||||
var lastUsedDate time.Time
|
||||
var ipAddresses string
|
||||
var numQueries int
|
||||
var lastQueried time.Time
|
||||
var versions string
|
||||
err = rows.Scan(®istrationDate, &numDevices, &numEntries, &lastUsedDate, &ipAddresses, &numQueries, &lastQueried, &versions)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
versions = strings.ReplaceAll(strings.ReplaceAll(versions, "Unknown", ""), ", ", "")
|
||||
lastQueryStr := strings.ReplaceAll(lastQueried.Format("2006-01-02"), "1970-01-01", "")
|
||||
tbl.AddRow(registrationDate.Format("2006-01-02"), numDevices, numEntries, numQueries, lastUsedDate.Format("2006-01-02"), lastQueryStr, versions, ipAddresses)
|
||||
}
|
||||
tbl.Print()
|
||||
}
|
||||
|
||||
func statsHandler(w http.ResponseWriter, r *http.Request) {
|
||||
var numDevices int64 = 0
|
||||
checkGormResult(GLOBAL_DB.Model(&shared.Device{}).Count(&numDevices))
|
||||
type numEntriesProcessed struct {
|
||||
Total int
|
||||
}
|
||||
nep := numEntriesProcessed{}
|
||||
checkGormResult(GLOBAL_DB.Model(&UsageData{}).Select("SUM(num_entries_handled) as total").Find(&nep))
|
||||
var numDbEntries int64 = 0
|
||||
checkGormResult(GLOBAL_DB.Model(&shared.EncHistoryEntry{}).Count(&numDbEntries))
|
||||
|
||||
lastWeek := time.Now().AddDate(0, 0, -7)
|
||||
var weeklyActiveInstalls int64 = 0
|
||||
checkGormResult(GLOBAL_DB.Model(&UsageData{}).Where("last_used > ?", lastWeek).Count(&weeklyActiveInstalls))
|
||||
var weeklyQueryUsers int64 = 0
|
||||
checkGormResult(GLOBAL_DB.Model(&UsageData{}).Where("last_queried > ?", lastWeek).Count(&weeklyQueryUsers))
|
||||
w.Write([]byte(fmt.Sprintf("Num devices: %d\n", numDevices)))
|
||||
w.Write([]byte(fmt.Sprintf("Num history entries processed: %d\n", nep.Total)))
|
||||
w.Write([]byte(fmt.Sprintf("Num DB entries: %d\n", numDbEntries)))
|
||||
w.Write([]byte(fmt.Sprintf("Weekly active installs: %d\n", weeklyActiveInstalls)))
|
||||
w.Write([]byte(fmt.Sprintf("Weekly active queries: %d\n", weeklyQueryUsers)))
|
||||
}
|
||||
|
||||
func apiSubmitHandler(w http.ResponseWriter, r *http.Request) {
|
||||
data, err := ioutil.ReadAll(r.Body)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
var entries []*shared.EncHistoryEntry
|
||||
err = json.Unmarshal(data, &entries)
|
||||
if err != nil {
|
||||
panic(fmt.Sprintf("body=%#v, err=%v", data, err))
|
||||
}
|
||||
fmt.Printf("apiSubmitHandler: received request containg %d EncHistoryEntry\n", len(entries))
|
||||
if len(entries) == 0 {
|
||||
return
|
||||
}
|
||||
updateUsageData(r, entries[0].UserId, entries[0].DeviceId, len(entries), false)
|
||||
tx := GLOBAL_DB.Where("user_id = ?", entries[0].UserId)
|
||||
var devices []*shared.Device
|
||||
checkGormResult(tx.Find(&devices))
|
||||
if len(devices) == 0 {
|
||||
panic(fmt.Errorf("found no devices associated with user_id=%s, can't save history entry", entries[0].UserId))
|
||||
}
|
||||
fmt.Printf("apiSubmitHandler: Found %d devices\n", len(devices))
|
||||
for _, device := range devices {
|
||||
for _, entry := range entries {
|
||||
entry.DeviceId = device.DeviceId
|
||||
}
|
||||
checkGormResult(GLOBAL_DB.Create(&entries))
|
||||
}
|
||||
}
|
||||
|
||||
func apiBootstrapHandler(w http.ResponseWriter, r *http.Request) {
|
||||
userId := getRequiredQueryParam(r, "user_id")
|
||||
deviceId := getRequiredQueryParam(r, "device_id")
|
||||
updateUsageData(r, userId, deviceId, 0, false)
|
||||
tx := GLOBAL_DB.Where("user_id = ?", userId)
|
||||
var historyEntries []*shared.EncHistoryEntry
|
||||
checkGormResult(tx.Find(&historyEntries))
|
||||
resp, err := json.Marshal(historyEntries)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
w.Write(resp)
|
||||
}
|
||||
|
||||
func apiQueryHandler(w http.ResponseWriter, r *http.Request) {
|
||||
userId := getRequiredQueryParam(r, "user_id")
|
||||
deviceId := getRequiredQueryParam(r, "device_id")
|
||||
updateUsageData(r, userId, deviceId, 0, true)
|
||||
// Increment the count
|
||||
checkGormResult(GLOBAL_DB.Exec("UPDATE enc_history_entries SET read_count = read_count + 1 WHERE device_id = ?", deviceId))
|
||||
|
||||
// Delete any entries that match a pending deletion request
|
||||
var deletionRequests []*shared.DeletionRequest
|
||||
checkGormResult(GLOBAL_DB.Where("destination_device_id = ? AND user_id = ?", deviceId, userId).Find(&deletionRequests))
|
||||
for _, request := range deletionRequests {
|
||||
_, err := applyDeletionRequestsToBackend(*request)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
}
|
||||
|
||||
// Then retrieve, to avoid a race condition
|
||||
tx := GLOBAL_DB.Where("device_id = ? AND read_count < 5", deviceId)
|
||||
var historyEntries []*shared.EncHistoryEntry
|
||||
checkGormResult(tx.Find(&historyEntries))
|
||||
fmt.Printf("apiQueryHandler: Found %d entries for %s\n", len(historyEntries), r.URL)
|
||||
resp, err := json.Marshal(historyEntries)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
w.Write(resp)
|
||||
}
|
||||
|
||||
func getRemoteAddr(r *http.Request) string {
|
||||
addr, ok := r.Header["X-Real-Ip"]
|
||||
if !ok || len(addr) == 0 {
|
||||
return "Unknown"
|
||||
}
|
||||
return addr[0]
|
||||
}
|
||||
|
||||
func apiRegisterHandler(w http.ResponseWriter, r *http.Request) {
|
||||
userId := getRequiredQueryParam(r, "user_id")
|
||||
deviceId := getRequiredQueryParam(r, "device_id")
|
||||
var existingDevicesCount int64 = -1
|
||||
checkGormResult(GLOBAL_DB.Model(&shared.Device{}).Where("user_id = ?", userId).Count(&existingDevicesCount))
|
||||
fmt.Printf("apiRegisterHandler: existingDevicesCount=%d\n", existingDevicesCount)
|
||||
checkGormResult(GLOBAL_DB.Create(&shared.Device{UserId: userId, DeviceId: deviceId, RegistrationIp: getRemoteAddr(r), RegistrationDate: time.Now()}))
|
||||
if existingDevicesCount > 0 {
|
||||
checkGormResult(GLOBAL_DB.Create(&shared.DumpRequest{UserId: userId, RequestingDeviceId: deviceId, RequestTime: time.Now()}))
|
||||
}
|
||||
updateUsageData(r, userId, deviceId, 0, false)
|
||||
}
|
||||
|
||||
func apiGetPendingDumpRequestsHandler(w http.ResponseWriter, r *http.Request) {
|
||||
userId := getRequiredQueryParam(r, "user_id")
|
||||
deviceId := getRequiredQueryParam(r, "device_id")
|
||||
var dumpRequests []*shared.DumpRequest
|
||||
// Filter out ones requested by the hishtory instance that sent this request
|
||||
checkGormResult(GLOBAL_DB.Where("user_id = ? AND requesting_device_id != ?", userId, deviceId).Find(&dumpRequests))
|
||||
respBody, err := json.Marshal(dumpRequests)
|
||||
if err != nil {
|
||||
panic(fmt.Errorf("failed to JSON marshall the dump requests: %v", err))
|
||||
}
|
||||
w.Write(respBody)
|
||||
}
|
||||
|
||||
func apiSubmitDumpHandler(w http.ResponseWriter, r *http.Request) {
|
||||
userId := getRequiredQueryParam(r, "user_id")
|
||||
srcDeviceId := getRequiredQueryParam(r, "source_device_id")
|
||||
requestingDeviceId := getRequiredQueryParam(r, "requesting_device_id")
|
||||
data, err := ioutil.ReadAll(r.Body)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
var entries []shared.EncHistoryEntry
|
||||
err = json.Unmarshal(data, &entries)
|
||||
if err != nil {
|
||||
panic(fmt.Sprintf("body=%#v, err=%v", data, err))
|
||||
}
|
||||
fmt.Printf("apiSubmitDumpHandler: received request containg %d EncHistoryEntry\n", len(entries))
|
||||
err = GLOBAL_DB.Transaction(func(tx *gorm.DB) error {
|
||||
for _, entry := range entries {
|
||||
entry.DeviceId = requestingDeviceId
|
||||
if entry.UserId != userId {
|
||||
return fmt.Errorf("batch contains an entry with UserId=%#v, when the query param contained the user_id=%#v", entry.UserId, userId)
|
||||
}
|
||||
checkGormResult(tx.Create(&entry))
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
panic(fmt.Errorf("failed to execute transaction to add dumped DB: %v", err))
|
||||
}
|
||||
checkGormResult(GLOBAL_DB.Delete(&shared.DumpRequest{}, "user_id = ? AND requesting_device_id = ?", userId, requestingDeviceId))
|
||||
updateUsageData(r, userId, srcDeviceId, len(entries), false)
|
||||
}
|
||||
|
||||
func apiBannerHandler(w http.ResponseWriter, r *http.Request) {
|
||||
commitHash := getRequiredQueryParam(r, "commit_hash")
|
||||
deviceId := getRequiredQueryParam(r, "device_id")
|
||||
forcedBanner := r.URL.Query().Get("forced_banner")
|
||||
fmt.Printf("apiBannerHandler: commit_hash=%#v, device_id=%#v, forced_banner=%#v\n", commitHash, deviceId, forcedBanner)
|
||||
if getHishtoryVersion(r) == "v0.160" {
|
||||
w.Write([]byte("Warning: hiSHtory v0.160 has a bug that slows down your shell! Please run `hishtory update` to upgrade hiSHtory."))
|
||||
return
|
||||
}
|
||||
w.Write([]byte(html.EscapeString(forcedBanner)))
|
||||
}
|
||||
|
||||
func getDeletionRequestsHandler(w http.ResponseWriter, r *http.Request) {
|
||||
userId := getRequiredQueryParam(r, "user_id")
|
||||
deviceId := getRequiredQueryParam(r, "device_id")
|
||||
|
||||
// Increment the ReadCount
|
||||
checkGormResult(GLOBAL_DB.Exec("UPDATE deletion_requests SET read_count = read_count + 1 WHERE destination_device_id = ? AND user_id = ?", deviceId, userId))
|
||||
|
||||
// Return all the deletion requests
|
||||
var deletionRequests []*shared.DeletionRequest
|
||||
checkGormResult(GLOBAL_DB.Where("user_id = ? AND destination_device_id = ?", userId, deviceId).Find(&deletionRequests))
|
||||
respBody, err := json.Marshal(deletionRequests)
|
||||
if err != nil {
|
||||
panic(fmt.Errorf("failed to JSON marshall the dump requests: %v", err))
|
||||
}
|
||||
w.Write(respBody)
|
||||
}
|
||||
|
||||
func addDeletionRequestHandler(w http.ResponseWriter, r *http.Request) {
|
||||
data, err := ioutil.ReadAll(r.Body)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
var request shared.DeletionRequest
|
||||
err = json.Unmarshal(data, &request)
|
||||
if err != nil {
|
||||
panic(fmt.Sprintf("body=%#v, err=%v", data, err))
|
||||
}
|
||||
request.ReadCount = 0
|
||||
fmt.Printf("addDeletionRequestHandler: received request containg %d messages to be deleted\n", len(request.Messages.Ids))
|
||||
|
||||
// Store the deletion request so all the devices will get it
|
||||
tx := GLOBAL_DB.Where("user_id = ?", request.UserId)
|
||||
var devices []*shared.Device
|
||||
checkGormResult(tx.Find(&devices))
|
||||
if len(devices) == 0 {
|
||||
panic(fmt.Errorf("found no devices associated with user_id=%s, can't save history entry", request.UserId))
|
||||
}
|
||||
fmt.Printf("addDeletionRequestHandler: Found %d devices\n", len(devices))
|
||||
for _, device := range devices {
|
||||
request.DestinationDeviceId = device.DeviceId
|
||||
checkGormResult(GLOBAL_DB.Create(&request))
|
||||
}
|
||||
|
||||
// Also delete anything currently in the DB matching it
|
||||
numDeleted, err := applyDeletionRequestsToBackend(request)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
fmt.Printf("addDeletionRequestHandler: Deleted %d rows in the backend\n", numDeleted)
|
||||
}
|
||||
|
||||
func healthCheckHandler(w http.ResponseWriter, r *http.Request) {
|
||||
var count int64
|
||||
checkGormResult(GLOBAL_DB.Model(&shared.EncHistoryEntry{}).Count(&count))
|
||||
if count < 100 {
|
||||
panic("Suspiciously few enc history entries!")
|
||||
}
|
||||
checkGormResult(GLOBAL_DB.Model(&shared.Device{}).Count(&count))
|
||||
if count < 50 {
|
||||
panic("Suspiciously few devices!")
|
||||
}
|
||||
ok := "OK"
|
||||
w.Write([]byte(ok))
|
||||
}
|
||||
|
||||
func applyDeletionRequestsToBackend(request shared.DeletionRequest) (int, error) {
|
||||
tx := GLOBAL_DB.Where("false")
|
||||
for _, message := range request.Messages.Ids {
|
||||
tx = tx.Or(GLOBAL_DB.Where("user_id = ? AND device_id = ? AND date = ?", request.UserId, message.DeviceId, message.Date))
|
||||
}
|
||||
result := tx.Delete(&shared.EncHistoryEntry{})
|
||||
checkGormResult(result)
|
||||
return int(result.RowsAffected), nil
|
||||
}
|
||||
|
||||
func wipeDbHandler(w http.ResponseWriter, r *http.Request) {
|
||||
checkGormResult(GLOBAL_DB.Exec("DELETE FROM enc_history_entries"))
|
||||
}
|
||||
|
||||
func isTestEnvironment() bool {
|
||||
u, err := user.Current()
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return os.Getenv("HISHTORY_TEST") != "" || u.Username == "david"
|
||||
}
|
||||
|
||||
func OpenDB() (*gorm.DB, error) {
|
||||
if isTestEnvironment() {
|
||||
db, err := gorm.Open(sqlite.Open("file::memory:?cache=shared"), &gorm.Config{})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to connect to the DB: %v", err)
|
||||
}
|
||||
db.AutoMigrate(&shared.EncHistoryEntry{})
|
||||
db.AutoMigrate(&shared.Device{})
|
||||
db.AutoMigrate(&UsageData{})
|
||||
db.AutoMigrate(&shared.DumpRequest{})
|
||||
db.AutoMigrate(&shared.DeletionRequest{})
|
||||
db.Exec("PRAGMA journal_mode = WAL")
|
||||
return db, nil
|
||||
}
|
||||
|
||||
postgresDb := fmt.Sprintf(PostgresDb, os.Getenv("POSTGRESQL_PASSWORD"))
|
||||
if os.Getenv("HISHTORY_POSTGRES_DB") != "" {
|
||||
postgresDb = os.Getenv("HISHTORY_POSTGRES_DB")
|
||||
}
|
||||
db, err := gorm.Open(postgres.Open(postgresDb), &gorm.Config{})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to connect to the DB: %v", err)
|
||||
}
|
||||
db.AutoMigrate(&shared.EncHistoryEntry{})
|
||||
db.AutoMigrate(&shared.Device{})
|
||||
db.AutoMigrate(&UsageData{})
|
||||
db.AutoMigrate(&shared.DumpRequest{})
|
||||
db.AutoMigrate(&shared.DeletionRequest{})
|
||||
return db, nil
|
||||
}
|
||||
|
||||
func init() {
|
||||
if ReleaseVersion == "UNKNOWN" && !isTestEnvironment() {
|
||||
panic("server.go was built without a ReleaseVersion!")
|
||||
}
|
||||
InitDB()
|
||||
go runBackgroundJobs()
|
||||
}
|
||||
|
||||
func cron() error {
|
||||
err := updateReleaseVersion()
|
||||
if err != nil {
|
||||
fmt.Println(err)
|
||||
}
|
||||
err = cleanDatabase()
|
||||
if err != nil {
|
||||
fmt.Println(err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func runBackgroundJobs() {
|
||||
time.Sleep(5 * time.Second)
|
||||
for {
|
||||
err := cron()
|
||||
if err != nil {
|
||||
fmt.Printf("Cron failure: %v", err)
|
||||
}
|
||||
time.Sleep(10 * time.Minute)
|
||||
}
|
||||
}
|
||||
|
||||
func triggerCronHandler(w http.ResponseWriter, r *http.Request) {
|
||||
err := cron()
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
}
|
||||
|
||||
type releaseInfo struct {
|
||||
Name string `json:"name"`
|
||||
}
|
||||
|
||||
func updateReleaseVersion() error {
|
||||
resp, err := http.Get("https://api.github.com/repos/ddworken/hishtory/releases/latest")
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get latest release version: %v", err)
|
||||
}
|
||||
respBody, err := ioutil.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to read github API response body: %v", err)
|
||||
}
|
||||
if resp.StatusCode == 403 && strings.Contains(string(respBody), "API rate limit exceeded for ") {
|
||||
return nil
|
||||
}
|
||||
if resp.StatusCode != 200 {
|
||||
return fmt.Errorf("failed to call github API, status_code=%d, body=%#v", resp.StatusCode, string(respBody))
|
||||
}
|
||||
var info releaseInfo
|
||||
err = json.Unmarshal(respBody, &info)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to parse github API response: %v", err)
|
||||
}
|
||||
latestVersionTag := info.Name
|
||||
ReleaseVersion = decrementVersionIfInvalid(latestVersionTag)
|
||||
return nil
|
||||
}
|
||||
|
||||
func decrementVersionIfInvalid(initialVersion string) string {
|
||||
// Decrements the version up to 5 times if the version doesn't have valid binaries yet.
|
||||
version := initialVersion
|
||||
for i := 0; i < 5; i++ {
|
||||
updateInfo := buildUpdateInfo(version)
|
||||
err := assertValidUpdate(updateInfo)
|
||||
if err == nil {
|
||||
fmt.Printf("Found a valid version: %v\n", version)
|
||||
return version
|
||||
}
|
||||
fmt.Printf("Found %s to be an invalid version: %v\n", version, err)
|
||||
version, err = decrementVersion(version)
|
||||
if err != nil {
|
||||
fmt.Printf("Failed to decrement version after finding the latest version was invalid: %v\n", err)
|
||||
return initialVersion
|
||||
}
|
||||
}
|
||||
fmt.Printf("Decremented the version 5 times and failed to find a valid version version number, initial version number: %v, last checked version number: %v\n", initialVersion, version)
|
||||
return initialVersion
|
||||
}
|
||||
|
||||
func assertValidUpdate(updateInfo shared.UpdateInfo) error {
|
||||
urls := []string{updateInfo.LinuxAmd64Url, updateInfo.LinuxAmd64AttestationUrl,
|
||||
updateInfo.DarwinAmd64Url, updateInfo.DarwinAmd64UnsignedUrl, updateInfo.DarwinAmd64AttestationUrl,
|
||||
updateInfo.DarwinArm64Url, updateInfo.DarwinArm64UnsignedUrl, updateInfo.DarwinArm64AttestationUrl}
|
||||
for _, url := range urls {
|
||||
resp, err := http.Get(url)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to retrieve URL %#v: %v", url, err)
|
||||
}
|
||||
if resp.StatusCode == 404 {
|
||||
return fmt.Errorf("URL %#v returned 404", url)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func InitDB() {
|
||||
var err error
|
||||
GLOBAL_DB, err = OpenDB()
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
tx, err := GLOBAL_DB.DB()
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
err = tx.Ping()
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
}
|
||||
|
||||
func decrementVersion(version string) (string, error) {
|
||||
if version == "UNKNOWN" {
|
||||
return "", fmt.Errorf("cannot decrement UNKNOWN")
|
||||
}
|
||||
parts := strings.Split(version, ".")
|
||||
if len(parts) != 2 {
|
||||
return "", fmt.Errorf("invalid version: %s", version)
|
||||
}
|
||||
versionNumber, err := strconv.Atoi(parts[1])
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("invalid version: %s", version)
|
||||
}
|
||||
return parts[0] + "." + strconv.Itoa(versionNumber-1), nil
|
||||
}
|
||||
|
||||
func buildUpdateInfo(version string) shared.UpdateInfo {
|
||||
return shared.UpdateInfo{
|
||||
LinuxAmd64Url: fmt.Sprintf("https://github.com/ddworken/hishtory/releases/download/%s/hishtory-linux-amd64", version),
|
||||
LinuxAmd64AttestationUrl: fmt.Sprintf("https://github.com/ddworken/hishtory/releases/download/%s/hishtory-linux-amd64.intoto.jsonl", version),
|
||||
DarwinAmd64Url: fmt.Sprintf("https://github.com/ddworken/hishtory/releases/download/%s/hishtory-darwin-amd64", version),
|
||||
DarwinAmd64UnsignedUrl: fmt.Sprintf("https://github.com/ddworken/hishtory/releases/download/%s/hishtory-darwin-amd64-unsigned", version),
|
||||
DarwinAmd64AttestationUrl: fmt.Sprintf("https://github.com/ddworken/hishtory/releases/download/%s/hishtory-darwin-amd64.intoto.jsonl", version),
|
||||
DarwinArm64Url: fmt.Sprintf("https://github.com/ddworken/hishtory/releases/download/%s/hishtory-darwin-arm64", version),
|
||||
DarwinArm64UnsignedUrl: fmt.Sprintf("https://github.com/ddworken/hishtory/releases/download/%s/hishtory-darwin-arm64-unsigned", version),
|
||||
DarwinArm64AttestationUrl: fmt.Sprintf("https://github.com/ddworken/hishtory/releases/download/%s/hishtory-darwin-arm64.intoto.jsonl", version),
|
||||
Version: version,
|
||||
}
|
||||
}
|
||||
|
||||
func apiDownloadHandler(w http.ResponseWriter, r *http.Request) {
|
||||
updateInfo := buildUpdateInfo(ReleaseVersion)
|
||||
resp, err := json.Marshal(updateInfo)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
w.Write(resp)
|
||||
}
|
||||
|
||||
func slsaStatusHandler(w http.ResponseWriter, r *http.Request) {
|
||||
// returns "OK" unless there is a current SLSA bug
|
||||
v := getHishtoryVersion(r)
|
||||
if !strings.Contains(v, "v0.") {
|
||||
w.Write([]byte("OK"))
|
||||
return
|
||||
}
|
||||
vNum, err := strconv.Atoi(strings.Split(v, ".")[1])
|
||||
if err != nil {
|
||||
w.Write([]byte("OK"))
|
||||
return
|
||||
}
|
||||
if vNum < 159 {
|
||||
w.Write([]byte("Sigstore deployed a broken change. See https://github.com/slsa-framework/slsa-github-generator/issues/1163"))
|
||||
return
|
||||
}
|
||||
w.Write([]byte("OK"))
|
||||
}
|
||||
|
||||
type loggedResponseData struct {
|
||||
size int
|
||||
}
|
||||
|
||||
type loggingResponseWriter struct {
|
||||
http.ResponseWriter
|
||||
responseData *loggedResponseData
|
||||
}
|
||||
|
||||
func (r *loggingResponseWriter) Write(b []byte) (int, error) {
|
||||
size, err := r.ResponseWriter.Write(b)
|
||||
r.responseData.size += size
|
||||
return size, err
|
||||
}
|
||||
|
||||
func (r *loggingResponseWriter) WriteHeader(statusCode int) {
|
||||
r.ResponseWriter.WriteHeader(statusCode)
|
||||
}
|
||||
|
||||
func withLogging(h func(http.ResponseWriter, *http.Request)) http.Handler {
|
||||
logFn := func(rw http.ResponseWriter, r *http.Request) {
|
||||
var responseData loggedResponseData
|
||||
lrw := loggingResponseWriter{
|
||||
ResponseWriter: rw,
|
||||
responseData: &responseData,
|
||||
}
|
||||
start := time.Now()
|
||||
|
||||
h(&lrw, r)
|
||||
|
||||
duration := time.Since(start)
|
||||
fmt.Printf("%s %s %#v %s %s %s\n", getRemoteAddr(r), r.Method, r.RequestURI, getHishtoryVersion(r), duration.String(), byteCountToString(responseData.size))
|
||||
}
|
||||
return http.HandlerFunc(logFn)
|
||||
}
|
||||
|
||||
func byteCountToString(b int) string {
|
||||
const unit = 1000
|
||||
if b < unit {
|
||||
return fmt.Sprintf("%d B", b)
|
||||
}
|
||||
div, exp := int64(unit), 0
|
||||
for n := b / unit; n >= unit; n /= unit {
|
||||
div *= unit
|
||||
exp++
|
||||
}
|
||||
return fmt.Sprintf("%.1f %cB", float64(b)/float64(div), "kMG"[exp])
|
||||
}
|
||||
|
||||
func cleanDatabase() error {
|
||||
checkGormResult(GLOBAL_DB.Exec("DELETE FROM enc_history_entries WHERE read_count > 10"))
|
||||
checkGormResult(GLOBAL_DB.Exec("DELETE FROM deletion_requests WHERE read_count > 100"))
|
||||
// TODO(optimization): Clean the database by deleting entries for users that haven't been used in X amount of time
|
||||
return nil
|
||||
}
|
||||
|
||||
func main() {
|
||||
fmt.Println("Listening on localhost:8080")
|
||||
http.Handle("/api/v1/submit", withLogging(apiSubmitHandler))
|
||||
http.Handle("/api/v1/get-dump-requests", withLogging(apiGetPendingDumpRequestsHandler))
|
||||
http.Handle("/api/v1/submit-dump", withLogging(apiSubmitDumpHandler))
|
||||
http.Handle("/api/v1/query", withLogging(apiQueryHandler))
|
||||
http.Handle("/api/v1/bootstrap", withLogging(apiBootstrapHandler))
|
||||
http.Handle("/api/v1/register", withLogging(apiRegisterHandler))
|
||||
http.Handle("/api/v1/banner", withLogging(apiBannerHandler))
|
||||
http.Handle("/api/v1/download", withLogging(apiDownloadHandler))
|
||||
http.Handle("/api/v1/trigger-cron", withLogging(triggerCronHandler))
|
||||
http.Handle("/api/v1/get-deletion-requests", withLogging(getDeletionRequestsHandler))
|
||||
http.Handle("/api/v1/add-deletion-request", withLogging(addDeletionRequestHandler))
|
||||
http.Handle("/api/v1/slsa-status", withLogging(slsaStatusHandler))
|
||||
http.Handle("/healthcheck", withLogging(healthCheckHandler))
|
||||
http.Handle("/internal/api/v1/usage-stats", withLogging(usageStatsHandler))
|
||||
http.Handle("/internal/api/v1/stats", withLogging(statsHandler))
|
||||
if isTestEnvironment() {
|
||||
http.Handle("/api/v1/wipe-db", withLogging(wipeDbHandler))
|
||||
}
|
||||
log.Fatal(http.ListenAndServe(":8080", nil))
|
||||
}
|
||||
|
||||
func checkGormResult(result *gorm.DB) {
|
||||
if result.Error != nil {
|
||||
_, filename, line, _ := runtime.Caller(1)
|
||||
panic(fmt.Sprintf("DB error at %s:%d: %v", filename, line, result.Error))
|
||||
}
|
||||
}
|
||||
|
||||
// TODO(optimization): Maybe optimize the endpoints a bit to reduce the number of round trips required?
|
489
backend/server/server_test.go
Normal file
489
backend/server/server_test.go
Normal file
@@ -0,0 +1,489 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"io/ioutil"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/ddworken/hishtory/client/data"
|
||||
"github.com/ddworken/hishtory/shared"
|
||||
"github.com/ddworken/hishtory/shared/testutils"
|
||||
"github.com/go-test/deep"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
func TestESubmitThenQuery(t *testing.T) {
|
||||
// Set up
|
||||
InitDB()
|
||||
|
||||
// Register a few devices
|
||||
userId := data.UserId("key")
|
||||
devId1 := uuid.Must(uuid.NewRandom()).String()
|
||||
devId2 := uuid.Must(uuid.NewRandom()).String()
|
||||
otherUser := data.UserId("otherkey")
|
||||
otherDev := uuid.Must(uuid.NewRandom()).String()
|
||||
deviceReq := httptest.NewRequest(http.MethodGet, "/?device_id="+devId1+"&user_id="+userId, nil)
|
||||
apiRegisterHandler(nil, deviceReq)
|
||||
deviceReq = httptest.NewRequest(http.MethodGet, "/?device_id="+devId2+"&user_id="+userId, nil)
|
||||
apiRegisterHandler(nil, deviceReq)
|
||||
deviceReq = httptest.NewRequest(http.MethodGet, "/?device_id="+otherDev+"&user_id="+otherUser, nil)
|
||||
apiRegisterHandler(nil, deviceReq)
|
||||
|
||||
// Submit a few entries for different devices
|
||||
entry := testutils.MakeFakeHistoryEntry("ls ~/")
|
||||
encEntry, err := data.EncryptHistoryEntry("key", entry)
|
||||
testutils.Check(t, err)
|
||||
reqBody, err := json.Marshal([]shared.EncHistoryEntry{encEntry})
|
||||
testutils.Check(t, err)
|
||||
submitReq := httptest.NewRequest(http.MethodPost, "/", bytes.NewReader(reqBody))
|
||||
apiSubmitHandler(nil, submitReq)
|
||||
|
||||
// Query for device id 1
|
||||
w := httptest.NewRecorder()
|
||||
searchReq := httptest.NewRequest(http.MethodGet, "/?device_id="+devId1+"&user_id="+userId, nil)
|
||||
apiQueryHandler(w, searchReq)
|
||||
res := w.Result()
|
||||
defer res.Body.Close()
|
||||
respBody, err := ioutil.ReadAll(res.Body)
|
||||
testutils.Check(t, err)
|
||||
var retrievedEntries []*shared.EncHistoryEntry
|
||||
testutils.Check(t, json.Unmarshal(respBody, &retrievedEntries))
|
||||
if len(retrievedEntries) != 1 {
|
||||
t.Fatalf("Expected to retrieve 1 entry, found %d", len(retrievedEntries))
|
||||
}
|
||||
dbEntry := retrievedEntries[0]
|
||||
if dbEntry.DeviceId != devId1 {
|
||||
t.Fatalf("Response contains an incorrect device ID: %#v", *dbEntry)
|
||||
}
|
||||
if dbEntry.UserId != data.UserId("key") {
|
||||
t.Fatalf("Response contains an incorrect device ID: %#v", *dbEntry)
|
||||
}
|
||||
if dbEntry.ReadCount != 1 {
|
||||
t.Fatalf("db.ReadCount should have been 1, was %v", dbEntry.ReadCount)
|
||||
}
|
||||
decEntry, err := data.DecryptHistoryEntry("key", *dbEntry)
|
||||
testutils.Check(t, err)
|
||||
if !data.EntryEquals(decEntry, entry) {
|
||||
t.Fatalf("DB data is different than input! \ndb =%#v\ninput=%#v", *dbEntry, entry)
|
||||
}
|
||||
|
||||
// Same for device id 2
|
||||
w = httptest.NewRecorder()
|
||||
searchReq = httptest.NewRequest(http.MethodGet, "/?device_id="+devId2+"&user_id="+userId, nil)
|
||||
apiQueryHandler(w, searchReq)
|
||||
res = w.Result()
|
||||
defer res.Body.Close()
|
||||
respBody, err = ioutil.ReadAll(res.Body)
|
||||
testutils.Check(t, err)
|
||||
testutils.Check(t, json.Unmarshal(respBody, &retrievedEntries))
|
||||
if len(retrievedEntries) != 1 {
|
||||
t.Fatalf("Expected to retrieve 1 entry, found %d", len(retrievedEntries))
|
||||
}
|
||||
dbEntry = retrievedEntries[0]
|
||||
if dbEntry.DeviceId != devId2 {
|
||||
t.Fatalf("Response contains an incorrect device ID: %#v", *dbEntry)
|
||||
}
|
||||
if dbEntry.UserId != data.UserId("key") {
|
||||
t.Fatalf("Response contains an incorrect device ID: %#v", *dbEntry)
|
||||
}
|
||||
if dbEntry.ReadCount != 1 {
|
||||
t.Fatalf("db.ReadCount should have been 1, was %v", dbEntry.ReadCount)
|
||||
}
|
||||
decEntry, err = data.DecryptHistoryEntry("key", *dbEntry)
|
||||
testutils.Check(t, err)
|
||||
if !data.EntryEquals(decEntry, entry) {
|
||||
t.Fatalf("DB data is different than input! \ndb =%#v\ninput=%#v", *dbEntry, entry)
|
||||
}
|
||||
|
||||
// Bootstrap handler should return 2 entries, one for each device
|
||||
w = httptest.NewRecorder()
|
||||
searchReq = httptest.NewRequest(http.MethodGet, "/?user_id="+data.UserId("key")+"&device_id="+devId1, nil)
|
||||
apiBootstrapHandler(w, searchReq)
|
||||
res = w.Result()
|
||||
defer res.Body.Close()
|
||||
respBody, err = ioutil.ReadAll(res.Body)
|
||||
testutils.Check(t, err)
|
||||
testutils.Check(t, json.Unmarshal(respBody, &retrievedEntries))
|
||||
if len(retrievedEntries) != 2 {
|
||||
t.Fatalf("Expected to retrieve 2 entries, found %d", len(retrievedEntries))
|
||||
}
|
||||
}
|
||||
|
||||
func TestDumpRequestAndResponse(t *testing.T) {
|
||||
// Set up
|
||||
InitDB()
|
||||
|
||||
// Register a first device for two different users
|
||||
userId := data.UserId("dkey")
|
||||
devId1 := uuid.Must(uuid.NewRandom()).String()
|
||||
devId2 := uuid.Must(uuid.NewRandom()).String()
|
||||
otherUser := data.UserId("dOtherkey")
|
||||
otherDev1 := uuid.Must(uuid.NewRandom()).String()
|
||||
otherDev2 := uuid.Must(uuid.NewRandom()).String()
|
||||
deviceReq := httptest.NewRequest(http.MethodGet, "/?device_id="+devId1+"&user_id="+userId, nil)
|
||||
apiRegisterHandler(nil, deviceReq)
|
||||
deviceReq = httptest.NewRequest(http.MethodGet, "/?device_id="+devId2+"&user_id="+userId, nil)
|
||||
apiRegisterHandler(nil, deviceReq)
|
||||
deviceReq = httptest.NewRequest(http.MethodGet, "/?device_id="+otherDev1+"&user_id="+otherUser, nil)
|
||||
apiRegisterHandler(nil, deviceReq)
|
||||
deviceReq = httptest.NewRequest(http.MethodGet, "/?device_id="+otherDev2+"&user_id="+otherUser, nil)
|
||||
apiRegisterHandler(nil, deviceReq)
|
||||
|
||||
// Query for dump requests, there should be one for userId
|
||||
w := httptest.NewRecorder()
|
||||
apiGetPendingDumpRequestsHandler(w, httptest.NewRequest(http.MethodGet, "/?user_id="+userId+"&device_id="+devId1, nil))
|
||||
res := w.Result()
|
||||
defer res.Body.Close()
|
||||
respBody, err := ioutil.ReadAll(res.Body)
|
||||
testutils.Check(t, err)
|
||||
var dumpRequests []*shared.DumpRequest
|
||||
testutils.Check(t, json.Unmarshal(respBody, &dumpRequests))
|
||||
if len(dumpRequests) != 1 {
|
||||
t.Fatalf("expected one pending dump request, got %#v", dumpRequests)
|
||||
}
|
||||
dumpRequest := dumpRequests[0]
|
||||
if dumpRequest.RequestingDeviceId != devId2 {
|
||||
t.Fatalf("unexpected device ID")
|
||||
}
|
||||
if dumpRequest.UserId != userId {
|
||||
t.Fatalf("unexpected user ID")
|
||||
}
|
||||
|
||||
// And one for otherUser
|
||||
w = httptest.NewRecorder()
|
||||
apiGetPendingDumpRequestsHandler(w, httptest.NewRequest(http.MethodGet, "/?user_id="+otherUser+"&device_id="+otherDev1, nil))
|
||||
res = w.Result()
|
||||
defer res.Body.Close()
|
||||
respBody, err = ioutil.ReadAll(res.Body)
|
||||
testutils.Check(t, err)
|
||||
dumpRequests = make([]*shared.DumpRequest, 0)
|
||||
testutils.Check(t, json.Unmarshal(respBody, &dumpRequests))
|
||||
if len(dumpRequests) != 1 {
|
||||
t.Fatalf("expected one pending dump request, got %#v", dumpRequests)
|
||||
}
|
||||
dumpRequest = dumpRequests[0]
|
||||
if dumpRequest.RequestingDeviceId != otherDev2 {
|
||||
t.Fatalf("unexpected device ID")
|
||||
}
|
||||
if dumpRequest.UserId != otherUser {
|
||||
t.Fatalf("unexpected user ID")
|
||||
}
|
||||
|
||||
// And none if we query for a user ID that doesn't exit
|
||||
w = httptest.NewRecorder()
|
||||
apiGetPendingDumpRequestsHandler(w, httptest.NewRequest(http.MethodGet, "/?user_id=foo&device_id=bar", nil))
|
||||
res = w.Result()
|
||||
defer res.Body.Close()
|
||||
respBody, err = ioutil.ReadAll(res.Body)
|
||||
testutils.Check(t, err)
|
||||
if string(respBody) != "[]" {
|
||||
t.Fatalf("got unexpected respBody: %#v", string(respBody))
|
||||
}
|
||||
|
||||
// And none for a missing user ID
|
||||
w = httptest.NewRecorder()
|
||||
apiGetPendingDumpRequestsHandler(w, httptest.NewRequest(http.MethodGet, "/?user_id=%20&device_id=%20", nil))
|
||||
res = w.Result()
|
||||
defer res.Body.Close()
|
||||
respBody, err = ioutil.ReadAll(res.Body)
|
||||
testutils.Check(t, err)
|
||||
if string(respBody) != "[]" {
|
||||
t.Fatalf("got unexpected respBody: %#v", string(respBody))
|
||||
}
|
||||
|
||||
// Now submit a dump for userId
|
||||
entry1Dec := testutils.MakeFakeHistoryEntry("ls ~/")
|
||||
entry1, err := data.EncryptHistoryEntry("dkey", entry1Dec)
|
||||
testutils.Check(t, err)
|
||||
entry2Dec := testutils.MakeFakeHistoryEntry("aaaaaaáaaa")
|
||||
entry2, err := data.EncryptHistoryEntry("dkey", entry1Dec)
|
||||
testutils.Check(t, err)
|
||||
reqBody, err := json.Marshal([]shared.EncHistoryEntry{entry1, entry2})
|
||||
testutils.Check(t, err)
|
||||
submitReq := httptest.NewRequest(http.MethodPost, "/?user_id="+userId+"&requesting_device_id="+devId2+"&source_device_id="+devId1, bytes.NewReader(reqBody))
|
||||
apiSubmitDumpHandler(nil, submitReq)
|
||||
|
||||
// Check that the dump request is no longer there for userId for either device ID
|
||||
w = httptest.NewRecorder()
|
||||
apiGetPendingDumpRequestsHandler(w, httptest.NewRequest(http.MethodGet, "/?user_id="+userId+"&device_id="+devId1, nil))
|
||||
res = w.Result()
|
||||
defer res.Body.Close()
|
||||
respBody, err = ioutil.ReadAll(res.Body)
|
||||
testutils.Check(t, err)
|
||||
if string(respBody) != "[]" {
|
||||
t.Fatalf("got unexpected respBody: %#v", string(respBody))
|
||||
}
|
||||
w = httptest.NewRecorder()
|
||||
// The other user
|
||||
apiGetPendingDumpRequestsHandler(w, httptest.NewRequest(http.MethodGet, "/?user_id="+userId+"&device_id="+devId2, nil))
|
||||
res = w.Result()
|
||||
defer res.Body.Close()
|
||||
respBody, err = ioutil.ReadAll(res.Body)
|
||||
testutils.Check(t, err)
|
||||
if string(respBody) != "[]" {
|
||||
t.Fatalf("got unexpected respBody: %#v", string(respBody))
|
||||
}
|
||||
|
||||
// But it is there for the other user
|
||||
w = httptest.NewRecorder()
|
||||
apiGetPendingDumpRequestsHandler(w, httptest.NewRequest(http.MethodGet, "/?user_id="+otherUser+"&device_id="+otherDev1, nil))
|
||||
res = w.Result()
|
||||
defer res.Body.Close()
|
||||
respBody, err = ioutil.ReadAll(res.Body)
|
||||
testutils.Check(t, err)
|
||||
dumpRequests = make([]*shared.DumpRequest, 0)
|
||||
testutils.Check(t, json.Unmarshal(respBody, &dumpRequests))
|
||||
if len(dumpRequests) != 1 {
|
||||
t.Fatalf("expected one pending dump request, got %#v", dumpRequests)
|
||||
}
|
||||
dumpRequest = dumpRequests[0]
|
||||
if dumpRequest.RequestingDeviceId != otherDev2 {
|
||||
t.Fatalf("unexpected device ID")
|
||||
}
|
||||
if dumpRequest.UserId != otherUser {
|
||||
t.Fatalf("unexpected user ID")
|
||||
}
|
||||
|
||||
// And finally, query to ensure that the dumped entries are in the DB
|
||||
w = httptest.NewRecorder()
|
||||
searchReq := httptest.NewRequest(http.MethodGet, "/?device_id="+devId2+"&user_id="+userId, nil)
|
||||
apiQueryHandler(w, searchReq)
|
||||
res = w.Result()
|
||||
defer res.Body.Close()
|
||||
respBody, err = ioutil.ReadAll(res.Body)
|
||||
testutils.Check(t, err)
|
||||
var retrievedEntries []*shared.EncHistoryEntry
|
||||
testutils.Check(t, json.Unmarshal(respBody, &retrievedEntries))
|
||||
if len(retrievedEntries) != 2 {
|
||||
t.Fatalf("Expected to retrieve 2 entries, found %d", len(retrievedEntries))
|
||||
}
|
||||
for _, dbEntry := range retrievedEntries {
|
||||
if dbEntry.DeviceId != devId2 {
|
||||
t.Fatalf("Response contains an incorrect device ID: %#v", *dbEntry)
|
||||
}
|
||||
if dbEntry.UserId != userId {
|
||||
t.Fatalf("Response contains an incorrect user ID: %#v", *dbEntry)
|
||||
}
|
||||
if dbEntry.ReadCount != 1 {
|
||||
t.Fatalf("db.ReadCount should have been 1, was %v", dbEntry.ReadCount)
|
||||
}
|
||||
decEntry, err := data.DecryptHistoryEntry("dkey", *dbEntry)
|
||||
testutils.Check(t, err)
|
||||
if !data.EntryEquals(decEntry, entry1Dec) && !data.EntryEquals(decEntry, entry2Dec) {
|
||||
t.Fatalf("DB data is different than input! \ndb =%#v\nentry1=%#v\nentry2=%#v", *dbEntry, entry1Dec, entry2Dec)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateReleaseVersion(t *testing.T) {
|
||||
if !testutils.IsOnline() {
|
||||
t.Skip("skipping because we're currently offline")
|
||||
}
|
||||
|
||||
// Set up
|
||||
InitDB()
|
||||
|
||||
// Check that ReleaseVersion hasn't been set yet
|
||||
if ReleaseVersion != "UNKNOWN" {
|
||||
t.Fatalf("initial ReleaseVersion isn't as expected: %#v", ReleaseVersion)
|
||||
}
|
||||
|
||||
// Update it
|
||||
err := updateReleaseVersion()
|
||||
if err != nil {
|
||||
t.Fatalf("updateReleaseVersion failed: %v", err)
|
||||
}
|
||||
|
||||
// If ReleaseVersion is still unknown, skip because we're getting rate limited
|
||||
if ReleaseVersion == "UNKNOWN" {
|
||||
t.Skip()
|
||||
}
|
||||
// Otherwise, check that the new value looks reasonable
|
||||
if !strings.HasPrefix(ReleaseVersion, "v0.") {
|
||||
t.Fatalf("ReleaseVersion wasn't updated to contain a version: %#v", ReleaseVersion)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeletionRequests(t *testing.T) {
|
||||
// Set up
|
||||
InitDB()
|
||||
|
||||
// Register two devices for two different users
|
||||
userId := data.UserId("dkey")
|
||||
devId1 := uuid.Must(uuid.NewRandom()).String()
|
||||
devId2 := uuid.Must(uuid.NewRandom()).String()
|
||||
otherUser := data.UserId("dOtherkey")
|
||||
otherDev1 := uuid.Must(uuid.NewRandom()).String()
|
||||
otherDev2 := uuid.Must(uuid.NewRandom()).String()
|
||||
deviceReq := httptest.NewRequest(http.MethodGet, "/?device_id="+devId1+"&user_id="+userId, nil)
|
||||
apiRegisterHandler(nil, deviceReq)
|
||||
deviceReq = httptest.NewRequest(http.MethodGet, "/?device_id="+devId2+"&user_id="+userId, nil)
|
||||
apiRegisterHandler(nil, deviceReq)
|
||||
deviceReq = httptest.NewRequest(http.MethodGet, "/?device_id="+otherDev1+"&user_id="+otherUser, nil)
|
||||
apiRegisterHandler(nil, deviceReq)
|
||||
deviceReq = httptest.NewRequest(http.MethodGet, "/?device_id="+otherDev2+"&user_id="+otherUser, nil)
|
||||
apiRegisterHandler(nil, deviceReq)
|
||||
|
||||
// Add an entry for user1
|
||||
entry1 := testutils.MakeFakeHistoryEntry("ls ~/")
|
||||
entry1.DeviceId = devId1
|
||||
encEntry, err := data.EncryptHistoryEntry("dkey", entry1)
|
||||
testutils.Check(t, err)
|
||||
reqBody, err := json.Marshal([]shared.EncHistoryEntry{encEntry})
|
||||
testutils.Check(t, err)
|
||||
submitReq := httptest.NewRequest(http.MethodPost, "/", bytes.NewReader(reqBody))
|
||||
apiSubmitHandler(nil, submitReq)
|
||||
|
||||
// And another entry for user1
|
||||
entry2 := testutils.MakeFakeHistoryEntry("ls /foo/bar")
|
||||
entry2.DeviceId = devId2
|
||||
encEntry, err = data.EncryptHistoryEntry("dkey", entry2)
|
||||
testutils.Check(t, err)
|
||||
reqBody, err = json.Marshal([]shared.EncHistoryEntry{encEntry})
|
||||
testutils.Check(t, err)
|
||||
submitReq = httptest.NewRequest(http.MethodPost, "/", bytes.NewReader(reqBody))
|
||||
apiSubmitHandler(nil, submitReq)
|
||||
|
||||
// And an entry for user2 that has the same timestamp as the previous entry
|
||||
entry3 := testutils.MakeFakeHistoryEntry("ls /foo/bar")
|
||||
entry3.StartTime = entry1.StartTime
|
||||
entry3.EndTime = entry1.EndTime
|
||||
encEntry, err = data.EncryptHistoryEntry("dOtherkey", entry3)
|
||||
testutils.Check(t, err)
|
||||
reqBody, err = json.Marshal([]shared.EncHistoryEntry{encEntry})
|
||||
testutils.Check(t, err)
|
||||
submitReq = httptest.NewRequest(http.MethodPost, "/", bytes.NewReader(reqBody))
|
||||
apiSubmitHandler(nil, submitReq)
|
||||
|
||||
// Query for device id 1
|
||||
w := httptest.NewRecorder()
|
||||
searchReq := httptest.NewRequest(http.MethodGet, "/?device_id="+devId1+"&user_id="+userId, nil)
|
||||
apiQueryHandler(w, searchReq)
|
||||
res := w.Result()
|
||||
defer res.Body.Close()
|
||||
respBody, err := ioutil.ReadAll(res.Body)
|
||||
testutils.Check(t, err)
|
||||
var retrievedEntries []*shared.EncHistoryEntry
|
||||
testutils.Check(t, json.Unmarshal(respBody, &retrievedEntries))
|
||||
if len(retrievedEntries) != 2 {
|
||||
t.Fatalf("Expected to retrieve 1 entry, found %d", len(retrievedEntries))
|
||||
}
|
||||
for _, dbEntry := range retrievedEntries {
|
||||
if dbEntry.DeviceId != devId1 {
|
||||
t.Fatalf("Response contains an incorrect device ID: %#v", *dbEntry)
|
||||
}
|
||||
if dbEntry.UserId != data.UserId("dkey") {
|
||||
t.Fatalf("Response contains an incorrect device ID: %#v", *dbEntry)
|
||||
}
|
||||
if dbEntry.ReadCount != 1 {
|
||||
t.Fatalf("db.ReadCount should have been 1, was %v", dbEntry.ReadCount)
|
||||
}
|
||||
decEntry, err := data.DecryptHistoryEntry("dkey", *dbEntry)
|
||||
testutils.Check(t, err)
|
||||
if !data.EntryEquals(decEntry, entry1) && !data.EntryEquals(decEntry, entry2) {
|
||||
t.Fatalf("DB data is different than input! \ndb =%#v\nentry1=%#v\nentry2=%#v", *dbEntry, entry1, entry2)
|
||||
}
|
||||
}
|
||||
|
||||
// Submit a redact request for entry1
|
||||
delReqTime := time.Now()
|
||||
delReq := shared.DeletionRequest{
|
||||
UserId: data.UserId("dkey"),
|
||||
SendTime: delReqTime,
|
||||
Messages: shared.MessageIdentifiers{Ids: []shared.MessageIdentifier{
|
||||
{DeviceId: devId1, Date: entry1.EndTime},
|
||||
}},
|
||||
}
|
||||
reqBody, err = json.Marshal(delReq)
|
||||
testutils.Check(t, err)
|
||||
req := httptest.NewRequest(http.MethodPost, "/", bytes.NewReader(reqBody))
|
||||
addDeletionRequestHandler(nil, req)
|
||||
|
||||
// Query again for device id 1 and get a single result
|
||||
w = httptest.NewRecorder()
|
||||
searchReq = httptest.NewRequest(http.MethodGet, "/?device_id="+devId1+"&user_id="+userId, nil)
|
||||
apiQueryHandler(w, searchReq)
|
||||
res = w.Result()
|
||||
defer res.Body.Close()
|
||||
respBody, err = ioutil.ReadAll(res.Body)
|
||||
testutils.Check(t, err)
|
||||
testutils.Check(t, json.Unmarshal(respBody, &retrievedEntries))
|
||||
if len(retrievedEntries) != 1 {
|
||||
t.Fatalf("Expected to retrieve 1 entry, found %d", len(retrievedEntries))
|
||||
}
|
||||
dbEntry := retrievedEntries[0]
|
||||
if dbEntry.DeviceId != devId1 {
|
||||
t.Fatalf("Response contains an incorrect device ID: %#v", *dbEntry)
|
||||
}
|
||||
if dbEntry.UserId != data.UserId("dkey") {
|
||||
t.Fatalf("Response contains an incorrect device ID: %#v", *dbEntry)
|
||||
}
|
||||
if dbEntry.ReadCount != 2 {
|
||||
t.Fatalf("db.ReadCount should have been 1, was %v", dbEntry.ReadCount)
|
||||
}
|
||||
decEntry, err := data.DecryptHistoryEntry("dkey", *dbEntry)
|
||||
testutils.Check(t, err)
|
||||
if !data.EntryEquals(decEntry, entry2) {
|
||||
t.Fatalf("DB data is different than input! \ndb =%#v\nentry=%#v", *dbEntry, entry2)
|
||||
}
|
||||
|
||||
// Query for user 2
|
||||
w = httptest.NewRecorder()
|
||||
searchReq = httptest.NewRequest(http.MethodGet, "/?device_id="+otherDev1+"&user_id="+otherUser, nil)
|
||||
apiQueryHandler(w, searchReq)
|
||||
res = w.Result()
|
||||
defer res.Body.Close()
|
||||
respBody, err = ioutil.ReadAll(res.Body)
|
||||
testutils.Check(t, err)
|
||||
testutils.Check(t, json.Unmarshal(respBody, &retrievedEntries))
|
||||
if len(retrievedEntries) != 1 {
|
||||
t.Fatalf("Expected to retrieve 1 entry, found %d", len(retrievedEntries))
|
||||
}
|
||||
dbEntry = retrievedEntries[0]
|
||||
if dbEntry.DeviceId != otherDev1 {
|
||||
t.Fatalf("Response contains an incorrect device ID: %#v", *dbEntry)
|
||||
}
|
||||
if dbEntry.UserId != data.UserId("dOtherkey") {
|
||||
t.Fatalf("Response contains an incorrect device ID: %#v", *dbEntry)
|
||||
}
|
||||
if dbEntry.ReadCount != 1 {
|
||||
t.Fatalf("db.ReadCount should have been 1, was %v", dbEntry.ReadCount)
|
||||
}
|
||||
decEntry, err = data.DecryptHistoryEntry("dOtherkey", *dbEntry)
|
||||
testutils.Check(t, err)
|
||||
if !data.EntryEquals(decEntry, entry3) {
|
||||
t.Fatalf("DB data is different than input! \ndb =%#v\nentry=%#v", *dbEntry, entry3)
|
||||
}
|
||||
|
||||
// Query for deletion requests
|
||||
w = httptest.NewRecorder()
|
||||
searchReq = httptest.NewRequest(http.MethodGet, "/?device_id="+devId1+"&user_id="+userId, nil)
|
||||
getDeletionRequestsHandler(w, searchReq)
|
||||
res = w.Result()
|
||||
defer res.Body.Close()
|
||||
respBody, err = ioutil.ReadAll(res.Body)
|
||||
testutils.Check(t, err)
|
||||
var deletionRequests []*shared.DeletionRequest
|
||||
testutils.Check(t, json.Unmarshal(respBody, &deletionRequests))
|
||||
if len(deletionRequests) != 1 {
|
||||
t.Fatalf("received %d deletion requests, expected only one", len(deletionRequests))
|
||||
}
|
||||
deletionRequest := deletionRequests[0]
|
||||
expected := shared.DeletionRequest{
|
||||
UserId: data.UserId("dkey"),
|
||||
DestinationDeviceId: devId1,
|
||||
SendTime: delReqTime,
|
||||
ReadCount: 1,
|
||||
Messages: shared.MessageIdentifiers{Ids: []shared.MessageIdentifier{
|
||||
{DeviceId: devId1, Date: entry1.EndTime},
|
||||
}},
|
||||
}
|
||||
if diff := deep.Equal(*deletionRequest, expected); diff != nil {
|
||||
t.Error(diff)
|
||||
}
|
||||
}
|
7
backend/web/caddy/Caddyfile
Normal file
7
backend/web/caddy/Caddyfile
Normal file
@@ -0,0 +1,7 @@
|
||||
hishtory.dev:80, localhost:80 {
|
||||
root /srv/landing
|
||||
gzip
|
||||
ext .html
|
||||
log stdout
|
||||
tls off
|
||||
}
|
6
backend/web/caddy/Dockerfile
Normal file
6
backend/web/caddy/Dockerfile
Normal file
@@ -0,0 +1,6 @@
|
||||
FROM abiosoft/caddy
|
||||
|
||||
LABEL "com.datadoghq.ad.logs"='[{"source": "caddy", "service": "web"}]'
|
||||
|
||||
COPY backend/web/caddy/Caddyfile /etc/
|
||||
COPY backend/web/landing/www/ /srv/landing
|
2
backend/web/landing/www/.gitignore
vendored
Normal file
2
backend/web/landing/www/.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
node_modules
|
||||
bower_components
|
BIN
backend/web/landing/www/img/demo.gif
Normal file
BIN
backend/web/landing/www/img/demo.gif
Normal file
Binary file not shown.
After Width: | Height: | Size: 1.1 MiB |
9
backend/web/landing/www/index.html
Normal file
9
backend/web/landing/www/index.html
Normal file
@@ -0,0 +1,9 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta http-equiv="refresh" content="0; URL='https://github.com/ddworken/hishtory'" />
|
||||
</head>
|
||||
<body>
|
||||
<p>See <a href="https://github.com/ddworken/hishtory">here</a>.</p>
|
||||
</body>
|
||||
</html>
|
40
backend/web/landing/www/install.py
Normal file
40
backend/web/landing/www/install.py
Normal file
@@ -0,0 +1,40 @@
|
||||
"""
|
||||
A small install script to download the correct hishtory binary for the current OS/architecture.
|
||||
The hishtory binary is in charge of installing itself, this just downloads the correct binary and
|
||||
executes it.
|
||||
"""
|
||||
|
||||
import json
|
||||
import urllib.request
|
||||
import platform
|
||||
import sys
|
||||
import os
|
||||
|
||||
with urllib.request.urlopen('https://api.hishtory.dev/api/v1/download') as response:
|
||||
resp_body = response.read()
|
||||
download_options = json.loads(resp_body)
|
||||
|
||||
if platform.system() == 'Linux':
|
||||
download_url = download_options['linux_amd_64_url']
|
||||
elif platform.system() == 'Darwin' and platform.machine() == 'arm64':
|
||||
download_url = download_options['darwin_arm_64_url']
|
||||
elif platform.system() == 'Darwin' and platform.machine() == 'x86_64':
|
||||
download_url = download_options['darwin_amd_64_url']
|
||||
else:
|
||||
print(f"No hishtory binary for system={platform.system()}, machine={platform.machine()}!\nIf you believe this is a mistake, please open an issue here: https://github.com/ddworken/hishtory/issues")
|
||||
sys.exit(1)
|
||||
|
||||
with urllib.request.urlopen(download_url) as response:
|
||||
hishtory_binary = response.read()
|
||||
|
||||
tmpdir = os.environ.get('TMPDIR', '') or '/tmp/'
|
||||
tmpFilePath = tmpdir+'hishtory-client'
|
||||
if os.path.exists(tmpFilePath):
|
||||
os.remove(tmpFilePath)
|
||||
with open(tmpFilePath, 'wb') as f:
|
||||
f.write(hishtory_binary)
|
||||
os.system('chmod +x ' + tmpFilePath)
|
||||
exitCode = os.system(tmpFilePath + ' install')
|
||||
if exitCode != 0:
|
||||
raise Exception("failed to install downloaded hishtory client via `" + tmpFilePath +" install` (is that directory mounted noexec? Consider setting an alternate directory via the TMPDIR environment variable)!")
|
||||
print('Succesfully installed hishtory! Open a new terminal, try running a command, and then running `hishtory query`.')
|
2456
client/client_test.go
Normal file
2456
client/client_test.go
Normal file
File diff suppressed because it is too large
Load Diff
165
client/data/data.go
Normal file
165
client/data/data.go
Normal file
@@ -0,0 +1,165 @@
|
||||
package data
|
||||
|
||||
import (
|
||||
"crypto/aes"
|
||||
"crypto/cipher"
|
||||
"crypto/hmac"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"database/sql/driver"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"time"
|
||||
|
||||
"github.com/ddworken/hishtory/shared"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
const (
|
||||
KdfUserID = "user_id"
|
||||
KdfEncryptionKey = "encryption_key"
|
||||
CONFIG_PATH = ".hishtory.config"
|
||||
HISHTORY_PATH = ".hishtory"
|
||||
DB_PATH = ".hishtory.db"
|
||||
)
|
||||
|
||||
type HistoryEntry struct {
|
||||
LocalUsername string `json:"local_username" gorm:"uniqueIndex:compositeindex"`
|
||||
Hostname string `json:"hostname" gorm:"uniqueIndex:compositeindex"`
|
||||
Command string `json:"command" gorm:"uniqueIndex:compositeindex"`
|
||||
CurrentWorkingDirectory string `json:"current_working_directory" gorm:"uniqueIndex:compositeindex"`
|
||||
HomeDirectory string `json:"home_directory" gorm:"uniqueIndex:compositeindex"`
|
||||
ExitCode int `json:"exit_code" gorm:"uniqueIndex:compositeindex"`
|
||||
StartTime time.Time `json:"start_time" gorm:"uniqueIndex:compositeindex"`
|
||||
EndTime time.Time `json:"end_time" gorm:"uniqueIndex:compositeindex"`
|
||||
DeviceId string `json:"device_id" gorm:"uniqueIndex:compositeindex"`
|
||||
CustomColumns CustomColumns `json:"custom_columns"`
|
||||
}
|
||||
|
||||
type CustomColumns []CustomColumn
|
||||
|
||||
type CustomColumn struct {
|
||||
Name string `json:"name"`
|
||||
Val string `json:"value"`
|
||||
}
|
||||
|
||||
func (c *CustomColumns) Scan(value interface{}) error {
|
||||
bytes, ok := value.([]byte)
|
||||
if !ok {
|
||||
return fmt.Errorf("failed to unmarshal CustomColumns value %#v", value)
|
||||
}
|
||||
|
||||
return json.Unmarshal(bytes, c)
|
||||
}
|
||||
|
||||
func (c CustomColumns) Value() (driver.Value, error) {
|
||||
return json.Marshal(c)
|
||||
}
|
||||
|
||||
func (h *HistoryEntry) GoString() string {
|
||||
return fmt.Sprintf("%#v", *h)
|
||||
}
|
||||
|
||||
func sha256hmac(key, additionalData string) []byte {
|
||||
h := hmac.New(sha256.New, []byte(key))
|
||||
h.Write([]byte(additionalData))
|
||||
return h.Sum(nil)
|
||||
}
|
||||
|
||||
func UserId(key string) string {
|
||||
return base64.URLEncoding.EncodeToString(sha256hmac(key, KdfUserID))
|
||||
}
|
||||
|
||||
func EncryptionKey(userSecret string) []byte {
|
||||
return sha256hmac(userSecret, KdfEncryptionKey)
|
||||
}
|
||||
|
||||
func makeAead(userSecret string) (cipher.AEAD, error) {
|
||||
key := EncryptionKey(userSecret)
|
||||
block, err := aes.NewCipher(key)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
aead, err := cipher.NewGCM(block)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return aead, nil
|
||||
}
|
||||
|
||||
func Encrypt(userSecret string, data, additionalData []byte) ([]byte, []byte, error) {
|
||||
aead, err := makeAead(userSecret)
|
||||
if err != nil {
|
||||
return []byte{}, []byte{}, fmt.Errorf("failed to make AEAD: %v", err)
|
||||
}
|
||||
nonce := make([]byte, 12)
|
||||
if _, err := io.ReadFull(rand.Reader, nonce); err != nil {
|
||||
return []byte{}, []byte{}, fmt.Errorf("failed to read a nonce: %v", err)
|
||||
}
|
||||
ciphertext := aead.Seal(nil, nonce, data, additionalData)
|
||||
_, err = aead.Open(nil, nonce, ciphertext, additionalData)
|
||||
if err != nil {
|
||||
return []byte{}, []byte{}, fmt.Errorf("failed to open AEAD: %v", err)
|
||||
}
|
||||
return ciphertext, nonce, nil
|
||||
}
|
||||
|
||||
func Decrypt(userSecret string, data, additionalData, nonce []byte) ([]byte, error) {
|
||||
aead, err := makeAead(userSecret)
|
||||
if err != nil {
|
||||
return []byte{}, fmt.Errorf("failed to make AEAD: %v", err)
|
||||
}
|
||||
plaintext, err := aead.Open(nil, nonce, data, additionalData)
|
||||
if err != nil {
|
||||
return []byte{}, fmt.Errorf("failed to decrypt: %v", err)
|
||||
}
|
||||
return plaintext, nil
|
||||
}
|
||||
|
||||
func EncryptHistoryEntry(userSecret string, entry HistoryEntry) (shared.EncHistoryEntry, error) {
|
||||
data, err := json.Marshal(entry)
|
||||
if err != nil {
|
||||
return shared.EncHistoryEntry{}, err
|
||||
}
|
||||
ciphertext, nonce, err := Encrypt(userSecret, data, []byte(UserId(userSecret)))
|
||||
if err != nil {
|
||||
return shared.EncHistoryEntry{}, err
|
||||
}
|
||||
return shared.EncHistoryEntry{
|
||||
EncryptedData: ciphertext,
|
||||
Nonce: nonce,
|
||||
UserId: UserId(userSecret),
|
||||
Date: entry.EndTime,
|
||||
EncryptedId: uuid.Must(uuid.NewRandom()).String(),
|
||||
ReadCount: 0,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func DecryptHistoryEntry(userSecret string, entry shared.EncHistoryEntry) (HistoryEntry, error) {
|
||||
if entry.UserId != UserId(userSecret) {
|
||||
return HistoryEntry{}, fmt.Errorf("refusing to decrypt history entry with mismatching UserId")
|
||||
}
|
||||
plaintext, err := Decrypt(userSecret, entry.EncryptedData, []byte(UserId(userSecret)), entry.Nonce)
|
||||
if err != nil {
|
||||
return HistoryEntry{}, nil
|
||||
}
|
||||
var decryptedEntry HistoryEntry
|
||||
err = json.Unmarshal(plaintext, &decryptedEntry)
|
||||
if err != nil {
|
||||
return HistoryEntry{}, nil
|
||||
}
|
||||
return decryptedEntry, nil
|
||||
}
|
||||
|
||||
func EntryEquals(entry1, entry2 HistoryEntry) bool {
|
||||
return entry1.LocalUsername == entry2.LocalUsername &&
|
||||
entry1.Hostname == entry2.Hostname &&
|
||||
entry1.Command == entry2.Command &&
|
||||
entry1.CurrentWorkingDirectory == entry2.CurrentWorkingDirectory &&
|
||||
entry1.HomeDirectory == entry2.HomeDirectory &&
|
||||
entry1.ExitCode == entry2.ExitCode &&
|
||||
entry1.StartTime.Format(time.RFC3339) == entry2.StartTime.Format(time.RFC3339) &&
|
||||
entry1.EndTime.Format(time.RFC3339) == entry2.EndTime.Format(time.RFC3339)
|
||||
}
|
61
client/data/data_test.go
Normal file
61
client/data/data_test.go
Normal file
@@ -0,0 +1,61 @@
|
||||
package data
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestEncryptDecrypt(t *testing.T) {
|
||||
k1 := EncryptionKey("key")
|
||||
k2 := EncryptionKey("key")
|
||||
if string(k1) != string(k2) {
|
||||
t.Fatalf("Expected EncryptionKey to be deterministic!")
|
||||
}
|
||||
|
||||
ciphertext, nonce, err := Encrypt("key", []byte("hello world!"), []byte("extra"))
|
||||
checkError(t, err)
|
||||
plaintext, err := Decrypt("key", ciphertext, []byte("extra"), nonce)
|
||||
checkError(t, err)
|
||||
if string(plaintext) != "hello world!" {
|
||||
t.Fatalf("Expected decrypt(encrypt(x)) to work, but it didn't!")
|
||||
}
|
||||
}
|
||||
|
||||
func checkError(t *testing.T, err error) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCustomColumnSerialization(t *testing.T) {
|
||||
cc1 := CustomColumn{
|
||||
Name: "name1",
|
||||
Val: "val1",
|
||||
}
|
||||
cc2 := CustomColumn{
|
||||
Name: "name2",
|
||||
Val: "val2",
|
||||
}
|
||||
var ccs CustomColumns = make(CustomColumns, 0)
|
||||
|
||||
// Empty array
|
||||
v, err := ccs.Value()
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected err: %v", err)
|
||||
}
|
||||
val := string(v.([]uint8))
|
||||
if val != "[]" {
|
||||
t.Fatalf("unexpected val for empty CustomColumns: %#v", val)
|
||||
}
|
||||
|
||||
// Non-empty array
|
||||
ccs = append(ccs, cc1, cc2)
|
||||
v, err = ccs.Value()
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected err: %v", err)
|
||||
}
|
||||
val = string(v.([]uint8))
|
||||
if val != "[{\"name\":\"name1\",\"value\":\"val1\"},{\"name\":\"name2\",\"value\":\"val2\"}]" {
|
||||
t.Fatalf("unexpected val for empty CustomColumns: %#v", val)
|
||||
}
|
||||
|
||||
}
|
263
client/hctx/hctx.go
Normal file
263
client/hctx/hctx.go
Normal file
@@ -0,0 +1,263 @@
|
||||
package hctx
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io/ioutil"
|
||||
"os"
|
||||
"path"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/ddworken/hishtory/client/data"
|
||||
"github.com/sirupsen/logrus"
|
||||
"gopkg.in/natefinch/lumberjack.v2"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/logger"
|
||||
|
||||
// Needed to use sqlite without CGO
|
||||
"github.com/glebarez/sqlite"
|
||||
)
|
||||
|
||||
var (
|
||||
hishtoryLogger *logrus.Logger
|
||||
getLoggerOnce sync.Once
|
||||
)
|
||||
|
||||
func GetLogger() *logrus.Logger {
|
||||
getLoggerOnce.Do(func() {
|
||||
homedir, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
panic(fmt.Errorf("failed to get user's home directory: %v", err))
|
||||
}
|
||||
err = MakeHishtoryDir()
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
lumberjackLogger := &lumberjack.Logger{
|
||||
Filename: path.Join(homedir, data.HISHTORY_PATH, "hishtory.log"),
|
||||
MaxSize: 1, // MB
|
||||
MaxBackups: 10,
|
||||
MaxAge: 30, // days
|
||||
}
|
||||
|
||||
logFormatter := new(logrus.TextFormatter)
|
||||
logFormatter.TimestampFormat = time.RFC3339
|
||||
logFormatter.FullTimestamp = true
|
||||
|
||||
hishtoryLogger = logrus.New()
|
||||
hishtoryLogger.SetFormatter(logFormatter)
|
||||
hishtoryLogger.SetLevel(logrus.InfoLevel)
|
||||
hishtoryLogger.SetOutput(lumberjackLogger)
|
||||
})
|
||||
return hishtoryLogger
|
||||
}
|
||||
|
||||
func MakeHishtoryDir() error {
|
||||
homedir, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get user's home directory: %v", err)
|
||||
}
|
||||
err = os.MkdirAll(path.Join(homedir, data.HISHTORY_PATH), 0o744)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create ~/.hishtory dir: %v", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func OpenLocalSqliteDb() (*gorm.DB, error) {
|
||||
homedir, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get user's home directory: %v", err)
|
||||
}
|
||||
err = MakeHishtoryDir()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
newLogger := logger.New(
|
||||
GetLogger(),
|
||||
logger.Config{
|
||||
SlowThreshold: 100 * time.Millisecond,
|
||||
LogLevel: logger.Warn,
|
||||
IgnoreRecordNotFoundError: false,
|
||||
Colorful: false,
|
||||
},
|
||||
)
|
||||
dbFilePath := path.Join(homedir, data.HISHTORY_PATH, data.DB_PATH)
|
||||
dsn := fmt.Sprintf("file:%s?cache=shared&mode=rwc&_journal_mode=WAL", dbFilePath)
|
||||
db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{SkipDefaultTransaction: true, Logger: newLogger})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to connect to the DB: %v", err)
|
||||
}
|
||||
tx, err := db.DB()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
err = tx.Ping()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
db.AutoMigrate(&data.HistoryEntry{})
|
||||
db.Exec("PRAGMA journal_mode = WAL")
|
||||
return db, nil
|
||||
}
|
||||
|
||||
type hishtoryContextKey string
|
||||
|
||||
func MakeContext() *context.Context {
|
||||
ctx := context.Background()
|
||||
config, err := GetConfig()
|
||||
if err != nil {
|
||||
panic(fmt.Errorf("failed to retrieve config: %v", err))
|
||||
}
|
||||
ctx = context.WithValue(ctx, hishtoryContextKey("config"), config)
|
||||
db, err := OpenLocalSqliteDb()
|
||||
if err != nil {
|
||||
panic(fmt.Errorf("failed to open local DB: %v", err))
|
||||
}
|
||||
ctx = context.WithValue(ctx, hishtoryContextKey("db"), db)
|
||||
homedir, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
panic(fmt.Errorf("failed to get homedir: %v", err))
|
||||
}
|
||||
ctx = context.WithValue(ctx, hishtoryContextKey("homedir"), homedir)
|
||||
return &ctx
|
||||
}
|
||||
|
||||
func GetConf(ctx *context.Context) ClientConfig {
|
||||
v := (*ctx).Value(hishtoryContextKey("config"))
|
||||
if v != nil {
|
||||
return v.(ClientConfig)
|
||||
}
|
||||
panic(fmt.Errorf("failed to find config in ctx"))
|
||||
}
|
||||
|
||||
func GetDb(ctx *context.Context) *gorm.DB {
|
||||
v := (*ctx).Value(hishtoryContextKey("db"))
|
||||
if v != nil {
|
||||
return v.(*gorm.DB)
|
||||
}
|
||||
panic(fmt.Errorf("failed to find db in ctx"))
|
||||
}
|
||||
|
||||
func GetHome(ctx *context.Context) string {
|
||||
v := (*ctx).Value(hishtoryContextKey("homedir"))
|
||||
if v != nil {
|
||||
return v.(string)
|
||||
}
|
||||
panic(fmt.Errorf("failed to find homedir in ctx"))
|
||||
}
|
||||
|
||||
type ClientConfig struct {
|
||||
// The user secret that is used to derive encryption keys for syncing history entries
|
||||
UserSecret string `json:"user_secret"`
|
||||
// Whether hishtory recording is enabled
|
||||
IsEnabled bool `json:"is_enabled"`
|
||||
// A device ID used to track which history entry came from which device for remote syncing
|
||||
DeviceId string `json:"device_id"`
|
||||
// Used for skipping history entries prefixed with a space in bash
|
||||
LastSavedHistoryLine string `json:"last_saved_history_line"`
|
||||
// Used for uploading history entries that we failed to upload due to a missing network connection
|
||||
HaveMissedUploads bool `json:"have_missed_uploads"`
|
||||
MissedUploadTimestamp int64 `json:"missed_upload_timestamp"`
|
||||
// Used for avoiding double imports of .bash_history
|
||||
HaveCompletedInitialImport bool `json:"have_completed_initial_import"`
|
||||
// Whether control-r bindings are enabled
|
||||
ControlRSearchEnabled bool `json:"enable_control_r_search"`
|
||||
// The set of columns that the user wants to be displayed
|
||||
DisplayedColumns []string `json:"displayed_columns"`
|
||||
// Custom columns
|
||||
CustomColumns []CustomColumnDefinition `json:"custom_columns"`
|
||||
// Whether this is an offline instance of hishtory with no syncing
|
||||
IsOffline bool `json:"is_offline"`
|
||||
// Whether duplicate commands should be displayed
|
||||
FilterDuplicateCommands bool `json:"filter_duplicate_commands"`
|
||||
// A format string for the timestamp
|
||||
TimestampFormat string `json:"timestamp_format"`
|
||||
}
|
||||
|
||||
type CustomColumnDefinition struct {
|
||||
ColumnName string `json:"column_name"`
|
||||
ColumnCommand string `json:"column_command"`
|
||||
}
|
||||
|
||||
func GetConfigContents() ([]byte, error) {
|
||||
homedir, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to retrieve homedir: %v", err)
|
||||
}
|
||||
dat, err := os.ReadFile(path.Join(homedir, data.HISHTORY_PATH, data.CONFIG_PATH))
|
||||
if err != nil {
|
||||
files, err := ioutil.ReadDir(path.Join(homedir, data.HISHTORY_PATH))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read config file (and failed to list too): %v", err)
|
||||
}
|
||||
filenames := ""
|
||||
for _, file := range files {
|
||||
filenames += file.Name()
|
||||
filenames += ", "
|
||||
}
|
||||
return nil, fmt.Errorf("failed to read config file (files in ~/.hishtory/: %s): %v", filenames, err)
|
||||
}
|
||||
return dat, nil
|
||||
}
|
||||
|
||||
func GetConfig() (ClientConfig, error) {
|
||||
data, err := GetConfigContents()
|
||||
if err != nil {
|
||||
return ClientConfig{}, err
|
||||
}
|
||||
var config ClientConfig
|
||||
err = json.Unmarshal(data, &config)
|
||||
if err != nil {
|
||||
return ClientConfig{}, fmt.Errorf("failed to parse config file: %v", err)
|
||||
}
|
||||
if config.DisplayedColumns == nil || len(config.DisplayedColumns) == 0 {
|
||||
config.DisplayedColumns = []string{"Hostname", "CWD", "Timestamp", "Runtime", "Exit Code", "Command"}
|
||||
}
|
||||
if config.TimestampFormat == "" {
|
||||
config.TimestampFormat = "Jan 2 2006 15:04:05 MST"
|
||||
}
|
||||
return config, nil
|
||||
}
|
||||
|
||||
func SetConfig(config ClientConfig) error {
|
||||
serializedConfig, err := json.Marshal(config)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to serialize config: %v", err)
|
||||
}
|
||||
homedir, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to retrieve homedir: %v", err)
|
||||
}
|
||||
err = MakeHishtoryDir()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
configPath := path.Join(homedir, data.HISHTORY_PATH, data.CONFIG_PATH)
|
||||
stagedConfigPath := configPath + ".tmp"
|
||||
err = os.WriteFile(stagedConfigPath, serializedConfig, 0o644)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to write config: %v", err)
|
||||
}
|
||||
err = os.Rename(stagedConfigPath, configPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to replace config file with the updated version: %v", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func InitConfig() error {
|
||||
homedir, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = os.Stat(path.Join(homedir, data.HISHTORY_PATH, data.CONFIG_PATH))
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return SetConfig(ClientConfig{})
|
||||
}
|
||||
return err
|
||||
}
|
34
client/lib/config.fish
Normal file
34
client/lib/config.fish
Normal file
@@ -0,0 +1,34 @@
|
||||
function _hishtory_post_exec --on-event fish_postexec
|
||||
# Runs after <ENTER>, but before the command is executed
|
||||
set --global _hishtory_command $argv
|
||||
set --global _hishtory_start_time (date +%s)
|
||||
end
|
||||
|
||||
set --global _hishtory_first_prompt 1
|
||||
|
||||
function __hishtory_on_prompt --on-event fish_prompt
|
||||
# Runs after the command is executed in order to render the prompt
|
||||
# $? contains the exit code
|
||||
set _hishtory_exit_code $status
|
||||
if [ -n "$_hishtory_first_prompt" ]
|
||||
set --global -e _hishtory_first_prompt
|
||||
else if [ -n "$_hishtory_command" ]
|
||||
hishtory saveHistoryEntry fish $_hishtory_exit_code "$_hishtory_command" $_hishtory_start_time & # Background Run
|
||||
# hishtory saveHistoryEntry fish $_hishtory_exit_code "$_hishtory_command" $_hishtory_start_time # Foreground Run
|
||||
set --global -e _hishtory_command # Unset _hishtory_command so we don't double-save entries when fish_prompt is invoked but fish_postexec isn't
|
||||
end
|
||||
end
|
||||
|
||||
function __hishtory_on_control_r
|
||||
set -l tmp (mktemp -t fish.XXXXXX)
|
||||
set -x init_query (commandline -b)
|
||||
HISHTORY_TERM_INTEGRATION=1 hishtory tquery $init_query > $tmp
|
||||
set -l res $status
|
||||
commandline -f repaint
|
||||
if [ -s $tmp ]
|
||||
commandline -r (cat $tmp)
|
||||
end
|
||||
rm -f $tmp
|
||||
end
|
||||
|
||||
[ (hishtory config-get enable-control-r) = true ] && bind \cr __hishtory_on_control_r
|
46
client/lib/config.sh
Normal file
46
client/lib/config.sh
Normal file
@@ -0,0 +1,46 @@
|
||||
# This script should be sourced inside of .bashrc to integrate bash with hishtory
|
||||
|
||||
# Include guard. This file is sourced in multiple places, but we want it to only execute once.
|
||||
# This trick is from https://stackoverflow.com/questions/7518584/is-there-any-mechanism-in-shell-script-alike-include-guard-in-c
|
||||
if [ -n "$__hishtory_bash_config_sourced" ]; then return; fi
|
||||
__hishtory_bash_config_sourced=`date`
|
||||
|
||||
# Implementation of running before/after every command based on https://jichu4n.com/posts/debug-trap-and-prompt_command-in-bash/
|
||||
function __hishtory_precommand() {
|
||||
if [ -z "$HISHTORY_AT_PROMPT" ]; then
|
||||
return
|
||||
fi
|
||||
unset HISHTORY_AT_PROMPT
|
||||
|
||||
# Run before every command
|
||||
HISHTORY_START_TIME=`date +%s`
|
||||
}
|
||||
trap "__hishtory_precommand" DEBUG
|
||||
|
||||
HISHTORY_FIRST_PROMPT=1
|
||||
function __hishtory_postcommand() {
|
||||
EXIT_CODE=$?
|
||||
HISHTORY_AT_PROMPT=1
|
||||
|
||||
if [ -n "$HISHTORY_FIRST_PROMPT" ]; then
|
||||
unset HISHTORY_FIRST_PROMPT
|
||||
return
|
||||
fi
|
||||
|
||||
# Run after every prompt
|
||||
(hishtory saveHistoryEntry bash $EXIT_CODE "`history 1`" $HISHTORY_START_TIME &) # Background Run
|
||||
# hishtory saveHistoryEntry bash $EXIT_CODE "`history 1`" $HISHTORY_START_TIME # Foreground Run
|
||||
}
|
||||
PROMPT_COMMAND="__hishtory_postcommand; $PROMPT_COMMAND"
|
||||
export HISTTIMEFORMAT=$HISTTIMEFORMAT
|
||||
|
||||
__history_control_r() {
|
||||
READLINE_LINE=$(HISHTORY_TERM_INTEGRATION=1 hishtory tquery "$READLINE_LINE" | tr -d '\n')
|
||||
READLINE_POINT=0x7FFFFFFF
|
||||
}
|
||||
|
||||
__hishtory_bind_control_r() {
|
||||
bind -x '"\C-r": __history_control_r'
|
||||
}
|
||||
|
||||
[ "$(hishtory config-get enable-control-r)" = true ] && __hishtory_bind_control_r
|
37
client/lib/config.zsh
Normal file
37
client/lib/config.zsh
Normal file
@@ -0,0 +1,37 @@
|
||||
autoload -U add-zsh-hook
|
||||
add-zsh-hook zshaddhistory _hishtory_add
|
||||
add-zsh-hook precmd _hishtory_precmd
|
||||
|
||||
_hishtory_first_prompt=1
|
||||
|
||||
function _hishtory_add() {
|
||||
# Runs after <ENTER>, but before the command is executed
|
||||
# $1 contains the command that was run
|
||||
_hishtory_command=$1
|
||||
_hishtory_start_time=`date +%s`
|
||||
}
|
||||
|
||||
function _hishtory_precmd() {
|
||||
# Runs after the command is executed in order to render the prompt
|
||||
# $? contains the exit code
|
||||
_hishtory_exit_code=$?
|
||||
if [ -n "$_hishtory_first_prompt" ]; then
|
||||
unset _hishtory_first_prompt
|
||||
return
|
||||
fi
|
||||
(hishtory saveHistoryEntry zsh $_hishtory_exit_code "$_hishtory_command" $_hishtory_start_time &) # Background Run
|
||||
# hishtory saveHistoryEntry zsh $_hishtory_exit_code "$_hishtory_command" $_hishtory_start_time # Foreground Run
|
||||
}
|
||||
|
||||
_hishtory_widget() {
|
||||
BUFFER=$(HISHTORY_TERM_INTEGRATION=1 hishtory tquery $BUFFER | tr -d '\n')
|
||||
CURSOR=${#BUFFER}
|
||||
zle reset-prompt
|
||||
}
|
||||
|
||||
_hishtory_bind_control_r() {
|
||||
zle -N _hishtory_widget
|
||||
bindkey '^R' _hishtory_widget
|
||||
}
|
||||
|
||||
[ "$(hishtory config-get enable-control-r)" = true ] && _hishtory_bind_control_r
|
6
client/lib/goldens/TestFish-table
Normal file
6
client/lib/goldens/TestFish-table
Normal file
@@ -0,0 +1,6 @@
|
||||
CWD Hostname Exit Code Command
|
||||
/ ghaction-runner-hostname 0 hishtory config-set displayed-columns CWD Hostname 'Exit Code' Command
|
||||
/ ghaction-runner-hostname 0 ls /tmp/ &
|
||||
/ ghaction-runner-hostname 0 echo "foo"
|
||||
/ ghaction-runner-hostname 0 echo bar
|
||||
/ ghaction-runner-hostname 0 echo foo
|
3
client/lib/goldens/TestTimestampFormat-query
Normal file
3
client/lib/goldens/TestTimestampFormat-query
Normal file
@@ -0,0 +1,3 @@
|
||||
Hostname CWD Timestamp Runtime Exit Code Command
|
||||
localhost ~/foo/ 2022/Apr/16 01:03 24s 3 table_cmd2
|
||||
localhost /tmp/ 2022/Apr/16 01:03 4s 2 table_cmd1 aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
|
30
client/lib/goldens/TestTimestampFormat-tquery
Normal file
30
client/lib/goldens/TestTimestampFormat-tquery
Normal file
@@ -0,0 +1,30 @@
|
||||
-pipefail -tablesizing
|
||||
|
||||
|
||||
|
||||
Search Query: > -pipefail -tablesizing
|
||||
|
||||
┌───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┐
|
||||
│ Hostname CWD Timestamp Runtime Exit Code Command │
|
||||
│───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────│
|
||||
│ localhost ~/foo/ 2022/Apr/16 01:03 24s 3 table_cmd2 │
|
||||
│ localhost /tmp/ 2022/Apr/16 01:03 4s 2 table_cmd1 aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa… │
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
└───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┘
|
30
client/lib/goldens/TestTimestampFormat-tquery-isAction
Normal file
30
client/lib/goldens/TestTimestampFormat-tquery-isAction
Normal file
@@ -0,0 +1,30 @@
|
||||
-pipefail -tablesizing
|
||||
|
||||
|
||||
|
||||
Search Query: > -pipefail -tablesizing
|
||||
|
||||
┌───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┐
|
||||
│ Hostname CWD Timestamp Runtime Exit Code Command │
|
||||
│───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────│
|
||||
│ localhost ~/foo/ 2022/Apr/16 01:03 24s 3 table_cmd2 │
|
||||
│ localhost /tmp/ 2022/Apr/16 01:03 4s 2 table_cmd1 aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa… │
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
└───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┘
|
26
client/lib/goldens/TestTui-Initial
Normal file
26
client/lib/goldens/TestTui-Initial
Normal file
@@ -0,0 +1,26 @@
|
||||
Search Query: > ls
|
||||
|
||||
┌────────────────────────────────────────────────────────────────────────────────────────────────────────┐
|
||||
│ Hostname CWD Timestamp Runtime Exit Code Command │
|
||||
│────────────────────────────────────────────────────────────────────────────────────────────────────────│
|
||||
│ localhost /tmp/ Oct 17 2022 21:43:21 PDT 3s 2 echo 'aaaaaa bbbb' │
|
||||
│ localhost /tmp/ Oct 17 2022 21:43:16 PDT 3s 2 ls ~/ │
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
└────────────────────────────────────────────────────────────────────────────────────────────────────────┘
|
26
client/lib/goldens/TestTui-Search
Normal file
26
client/lib/goldens/TestTui-Search
Normal file
@@ -0,0 +1,26 @@
|
||||
Search Query: > ls
|
||||
|
||||
┌────────────────────────────────────────────────────────────────────────────────────────────────────────┐
|
||||
│ Hostname CWD Timestamp Runtime Exit Code Command │
|
||||
│────────────────────────────────────────────────────────────────────────────────────────────────────────│
|
||||
│ localhost /tmp/ Oct 17 2022 21:43:16 PDT 3s 2 ls ~/ │
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
└────────────────────────────────────────────────────────────────────────────────────────────────────────┘
|
14
client/lib/goldens/TestTui-SmallTerminal
Normal file
14
client/lib/goldens/TestTui-SmallTerminal
Normal file
@@ -0,0 +1,14 @@
|
||||
Search Query: > ls
|
||||
|
||||
┌────────────────────────────────────────────────────────────────────────────────────────┐
|
||||
│ Hostname CWD Timestamp Runtime Exit Code Command │
|
||||
│────────────────────────────────────────────────────────────────────────────────────────│
|
||||
│ localhost /tmp/ Oct 17 2022 21:43:21 PDT 3s 2 echo 'aaaaaa bbbb' │
|
||||
│ localhost /tmp/ Oct 17 2022 21:43:16 PDT 3s 2 ls ~/ │
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
└────────────────────────────────────────────────────────────────────────────────────────┘
|
26
client/lib/goldens/testControlR-AdvancedSearch
Normal file
26
client/lib/goldens/testControlR-AdvancedSearch
Normal file
@@ -0,0 +1,26 @@
|
||||
Search Query: > cwd:/tmp/ ls
|
||||
|
||||
┌────────────────────────────────────────────────────────────────────────────────────────────────────────┐
|
||||
│ Hostname CWD Timestamp Runtime Exit Code Command │
|
||||
│────────────────────────────────────────────────────────────────────────────────────────────────────────│
|
||||
│ localhost /tmp/ Oct 17 2022 21:43:26 PDT 3s 2 ls ~/bar/ │
|
||||
│ localhost /tmp/ Oct 17 2022 21:43:21 PDT 3s 2 ls ~/foo/ │
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
└────────────────────────────────────────────────────────────────────────────────────────────────────────┘
|
2
client/lib/goldens/testControlR-ControlC-bash
Normal file
2
client/lib/goldens/testControlR-ControlC-bash
Normal file
@@ -0,0 +1,2 @@
|
||||
bash-5.2$ source /Users/david/.bashrc
|
||||
bash-5.2$ echo
|
3
client/lib/goldens/testControlR-ControlC-fish
Normal file
3
client/lib/goldens/testControlR-ControlC-fish
Normal file
@@ -0,0 +1,3 @@
|
||||
Welcome to fish, the friendly interactive shell
|
||||
Type help for instructions on how to use fish
|
||||
david@Davids-MacBook-Air ~/c/hishtory (master)> echo 'aaaaaa bbbb'
|
1
client/lib/goldens/testControlR-ControlC-zsh
Normal file
1
client/lib/goldens/testControlR-ControlC-zsh
Normal file
@@ -0,0 +1 @@
|
||||
david@Davids-MacBook-Air hishtory % echo
|
26
client/lib/goldens/testControlR-Initial
Normal file
26
client/lib/goldens/testControlR-Initial
Normal file
@@ -0,0 +1,26 @@
|
||||
Search Query: > ls
|
||||
|
||||
┌────────────────────────────────────────────────────────────────────────────────────────────────────────┐
|
||||
│ Hostname CWD Timestamp Runtime Exit Code Command │
|
||||
│────────────────────────────────────────────────────────────────────────────────────────────────────────│
|
||||
│ localhost /tmp/ Oct 17 2022 21:43:36 PDT 3s 2 echo 'bar' & │
|
||||
│ localhost /tmp/ Oct 17 2022 21:43:31 PDT 3s 2 echo 'aaaaaa bbbb' │
|
||||
│ localhost /tmp/ Oct 17 2022 21:43:26 PDT 3s 2 ls ~/bar/ │
|
||||
│ localhost /tmp/ Oct 17 2022 21:43:21 PDT 3s 2 ls ~/foo/ │
|
||||
│ server /etc/ Oct 17 2022 21:43:16 PDT 3s 127 ls ~/ │
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
└────────────────────────────────────────────────────────────────────────────────────────────────────────┘
|
26
client/lib/goldens/testControlR-InitialSearch
Normal file
26
client/lib/goldens/testControlR-InitialSearch
Normal file
@@ -0,0 +1,26 @@
|
||||
Search Query: > ls
|
||||
|
||||
┌─────────────────────────────────────────────────────────────────────────────┐
|
||||
│ Hostname Exit Code Command foo │
|
||||
│─────────────────────────────────────────────────────────────────────────────│
|
||||
│ ghaction-runner-hostname 0 ls / foo │
|
||||
│ localhost 2 ls ~/bar/ │
|
||||
│ localhost 2 ls ~/foo/ │
|
||||
│ server 127 ls ~/ │
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
└─────────────────────────────────────────────────────────────────────────────┘
|
26
client/lib/goldens/testControlR-InitialSearchExpanded
Normal file
26
client/lib/goldens/testControlR-InitialSearchExpanded
Normal file
@@ -0,0 +1,26 @@
|
||||
Search Query: > echo
|
||||
|
||||
┌─────────────────────────────────────────────────────────────────────────────┐
|
||||
│ Hostname Exit Code Command foo │
|
||||
│─────────────────────────────────────────────────────────────────────────────│
|
||||
│ localhost 2 echo 'bar' & │
|
||||
│ localhost 2 echo 'aaaaaa bbbb' │
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
└─────────────────────────────────────────────────────────────────────────────┘
|
26
client/lib/goldens/testControlR-InitialSearchNoResults
Normal file
26
client/lib/goldens/testControlR-InitialSearchNoResults
Normal file
@@ -0,0 +1,26 @@
|
||||
Search Query: > asdf
|
||||
|
||||
┌─────────────────────────────────────────────────────────────────────────────┐
|
||||
│ Hostname Exit Code Command foo │
|
||||
│─────────────────────────────────────────────────────────────────────────────│
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
└─────────────────────────────────────────────────────────────────────────────┘
|
@@ -0,0 +1,26 @@
|
||||
Search Query: > echo
|
||||
|
||||
┌─────────────────────────────────────────────────────────────────────────────┐
|
||||
│ Hostname Exit Code Command foo │
|
||||
│─────────────────────────────────────────────────────────────────────────────│
|
||||
│ localhost 2 echo 'bar' & │
|
||||
│ localhost 2 echo 'aaaaaa bbbb' │
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
└─────────────────────────────────────────────────────────────────────────────┘
|
26
client/lib/goldens/testControlR-Search
Normal file
26
client/lib/goldens/testControlR-Search
Normal file
@@ -0,0 +1,26 @@
|
||||
Search Query: > echo
|
||||
|
||||
┌────────────────────────────────────────────────────────────────────────────────────────────────────────┐
|
||||
│ Hostname CWD Timestamp Runtime Exit Code Command │
|
||||
│────────────────────────────────────────────────────────────────────────────────────────────────────────│
|
||||
│ localhost /tmp/ Oct 17 2022 21:43:36 PDT 3s 2 echo 'bar' & │
|
||||
│ localhost /tmp/ Oct 17 2022 21:43:31 PDT 3s 2 echo 'aaaaaa bbbb' │
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
└────────────────────────────────────────────────────────────────────────────────────────────────────────┘
|
2
client/lib/goldens/testControlR-bash-Disabled
Normal file
2
client/lib/goldens/testControlR-bash-Disabled
Normal file
@@ -0,0 +1,2 @@
|
||||
bash-5.2$ source /Users/david/.bashrc
|
||||
(reverse-i-search)`':
|
26
client/lib/goldens/testControlR-customColumn
Normal file
26
client/lib/goldens/testControlR-customColumn
Normal file
@@ -0,0 +1,26 @@
|
||||
Search Query: > -pipefail
|
||||
|
||||
┌─────────────────────────────────────────────────────────────────────────────┐
|
||||
│ Hostname Exit Code Command foo │
|
||||
│─────────────────────────────────────────────────────────────────────────────│
|
||||
│ ghaction-runner-hostname 0 ls / foo │
|
||||
│ localhost 2 echo 'bar' & │
|
||||
│ localhost 2 echo 'aaaaaa bbbb' │
|
||||
│ localhost 2 ls ~/bar/ │
|
||||
│ localhost 2 ls ~/foo/ │
|
||||
│ server 127 ls ~/ │
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
└─────────────────────────────────────────────────────────────────────────────┘
|
26
client/lib/goldens/testControlR-displayedColumns
Normal file
26
client/lib/goldens/testControlR-displayedColumns
Normal file
@@ -0,0 +1,26 @@
|
||||
Search Query: > ls
|
||||
|
||||
┌────────────────────────────────────────────────────┐
|
||||
│ Hostname Exit Code Command │
|
||||
│────────────────────────────────────────────────────│
|
||||
│ localhost 2 echo 'bar' & │
|
||||
│ localhost 2 echo 'aaaaaa bbbb' │
|
||||
│ localhost 2 ls ~/bar/ │
|
||||
│ localhost 2 ls ~/foo/ │
|
||||
│ server 127 ls ~/ │
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
└────────────────────────────────────────────────────┘
|
3
client/lib/goldens/testControlR-fish-Disabled
Normal file
3
client/lib/goldens/testControlR-fish-Disabled
Normal file
@@ -0,0 +1,3 @@
|
||||
Welcome to fish, the friendly interactive shell
|
||||
Type help for instructions on how to use fish
|
||||
david@Davids-MacBook-Air ~/c/hishtory (master)>
|
2
client/lib/goldens/testControlR-zsh-Disabled
Normal file
2
client/lib/goldens/testControlR-zsh-Disabled
Normal file
@@ -0,0 +1,2 @@
|
||||
david@Davids-MacBook-Air hishtory %
|
||||
bck-i-search: _
|
4
client/lib/goldens/testCustomColumns-initHistory
Normal file
4
client/lib/goldens/testCustomColumns-initHistory
Normal file
@@ -0,0 +1,4 @@
|
||||
export FOOBAR='hello'
|
||||
echo $FOOBAR world
|
||||
cd /
|
||||
echo baz
|
10
client/lib/goldens/testCustomColumns-query-isAction=false
Normal file
10
client/lib/goldens/testCustomColumns-query-isAction=false
Normal file
@@ -0,0 +1,10 @@
|
||||
Exit Code git_remote Command
|
||||
0 git@github.com:ddworken/hishtory.git hishtory config-set displayed-columns 'Exit Code' git_remote Command
|
||||
0 echo bar
|
||||
0 cd /
|
||||
0 git@github.com:ddworken/hishtory.git echo foo
|
||||
0 git@github.com:ddworken/hishtory.git hishtory config-add custom-column git_remote '(git remote -v 2>/dev/null | grep origin 1>/dev/null ) && git remote get-url origin || true'
|
||||
0 echo baz
|
||||
0 cd /
|
||||
0 echo $FOOBAR world
|
||||
0 export FOOBAR='hello'
|
10
client/lib/goldens/testCustomColumns-query-isAction=true
Normal file
10
client/lib/goldens/testCustomColumns-query-isAction=true
Normal file
@@ -0,0 +1,10 @@
|
||||
Exit Code git_remote Command
|
||||
0 https://github.com/ddworken/hishtory hishtory config-set displayed-columns 'Exit Code' git_remote Command
|
||||
0 echo bar
|
||||
0 cd /
|
||||
0 https://github.com/ddworken/hishtory echo foo
|
||||
0 https://github.com/ddworken/hishtory hishtory config-add custom-column git_remote '(git remote -v 2>/dev/null | grep origin 1>/dev/null ) && git remote get-url origin || true'
|
||||
0 echo baz
|
||||
0 cd /
|
||||
0 echo $FOOBAR world
|
||||
0 export FOOBAR='hello'
|
31
client/lib/goldens/testCustomColumns-tquery-bash
Normal file
31
client/lib/goldens/testCustomColumns-tquery-bash
Normal file
@@ -0,0 +1,31 @@
|
||||
bash-5.2$ source /Users/david/.bashrc
|
||||
bash-5.2$ hishtory tquery -pipefail
|
||||
|
||||
|
||||
|
||||
Search Query: > -pipefail
|
||||
|
||||
┌─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┐
|
||||
│ Exit Code git_remote Command │
|
||||
│─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────│
|
||||
│ 0 git@github.com:ddworken/hishtory.git hishtory config-set displayed-columns 'Exit Code' git_remote Command │
|
||||
│ 0 echo bar │
|
||||
│ 0 cd / │
|
||||
│ 0 git@github.com:ddworken/hishtory.git echo foo │
|
||||
│ 0 git@github.com:ddworken/hishtory.git hishtory config-add custom-column git_remote '(git remote -v 2>/dev/null | grep origin 1>/dev/null ) && git remote get-url origin || … │
|
||||
│ 0 echo baz │
|
||||
│ 0 cd / │
|
||||
│ 0 echo $FOOBAR world │
|
||||
│ 0 export FOOBAR='hello' │
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
└─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┘
|
@@ -0,0 +1,31 @@
|
||||
bash-5.2$ source /Users/runner/.bashrc
|
||||
bash-5.2$ hishtory tquery -pipefail
|
||||
|
||||
|
||||
|
||||
Search Query: > -pipefail
|
||||
|
||||
┌─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┐
|
||||
│ Exit Code git_remote Command │
|
||||
│─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────│
|
||||
│ 0 https://github.com/ddworken/hishtory hishtory config-set displayed-columns 'Exit Code' git_remote Command │
|
||||
│ 0 echo bar │
|
||||
│ 0 cd / │
|
||||
│ 0 https://github.com/ddworken/hishtory echo foo │
|
||||
│ 0 https://github.com/ddworken/hishtory hishtory config-add custom-column git_remote '(git remote -v 2>/dev/null | grep origin 1>/dev/null ) && git remote get-url origin || … │
|
||||
│ 0 echo baz │
|
||||
│ 0 cd / │
|
||||
│ 0 echo $FOOBAR world │
|
||||
│ 0 export FOOBAR='hello' │
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
└─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┘
|
@@ -0,0 +1,31 @@
|
||||
runner@ghaction-runner-hostname:~/work/hishtory/hishtory$ source /home/runner/.bashrc
|
||||
runner@ghaction-runner-hostname:~/work/hishtory/hishtory$ hishtory tquery -pipefail
|
||||
|
||||
|
||||
|
||||
Search Query: > -pipefail
|
||||
|
||||
┌─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┐
|
||||
│ Exit Code git_remote Command │
|
||||
│─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────│
|
||||
│ 0 https://github.com/ddworken/hishtory hishtory config-set displayed-columns 'Exit Code' git_remote Command │
|
||||
│ 0 echo bar │
|
||||
│ 0 cd / │
|
||||
│ 0 https://github.com/ddworken/hishtory echo foo │
|
||||
│ 0 https://github.com/ddworken/hishtory hishtory config-add custom-column git_remote '(git remote -v 2>/dev/null | grep origin 1>/dev/null ) && git remote get-url origin || … │
|
||||
│ 0 echo baz │
|
||||
│ 0 cd / │
|
||||
│ 0 echo $FOOBAR world │
|
||||
│ 0 export FOOBAR='hello' │
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
└─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┘
|
30
client/lib/goldens/testCustomColumns-tquery-zsh
Normal file
30
client/lib/goldens/testCustomColumns-tquery-zsh
Normal file
@@ -0,0 +1,30 @@
|
||||
david@Davids-MacBook-Air hishtory % hishtory tquery -pipefail
|
||||
|
||||
|
||||
|
||||
Search Query: > -pipefail
|
||||
|
||||
┌─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┐
|
||||
│ Exit Code git_remote Command │
|
||||
│─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────│
|
||||
│ 0 git@github.com:ddworken/hishtory.git hishtory config-set displayed-columns 'Exit Code' git_remote Command │
|
||||
│ 0 echo bar │
|
||||
│ 0 cd / │
|
||||
│ 0 git@github.com:ddworken/hishtory.git echo foo │
|
||||
│ 0 git@github.com:ddworken/hishtory.git hishtory config-add custom-column git_remote '(git remote -v 2>/dev/null | grep origin 1>/dev/null ) && git remote get-url origin || … │
|
||||
│ 0 echo baz │
|
||||
│ 0 cd / │
|
||||
│ 0 echo $FOOBAR world │
|
||||
│ 0 export FOOBAR='hello' │
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
└─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┘
|
@@ -0,0 +1,30 @@
|
||||
runner@ghaction-runner-hostname hishtory % hishtory tquery -pipefail
|
||||
|
||||
|
||||
|
||||
Search Query: > -pipefail
|
||||
|
||||
┌─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┐
|
||||
│ Exit Code git_remote Command │
|
||||
│─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────│
|
||||
│ 0 https://github.com/ddworken/hishtory hishtory config-set displayed-columns 'Exit Code' git_remote Command │
|
||||
│ 0 echo bar │
|
||||
│ 0 cd / │
|
||||
│ 0 https://github.com/ddworken/hishtory echo foo │
|
||||
│ 0 https://github.com/ddworken/hishtory hishtory config-add custom-column git_remote '(git remote -v 2>/dev/null | grep origin 1>/dev/null ) && git remote get-url origin || … │
|
||||
│ 0 echo baz │
|
||||
│ 0 cd / │
|
||||
│ 0 echo $FOOBAR world │
|
||||
│ 0 export FOOBAR='hello' │
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
└─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┘
|
@@ -0,0 +1,30 @@
|
||||
ghaction-runner-hostname% hishtory tquery -pipefail
|
||||
|
||||
|
||||
|
||||
Search Query: > -pipefail
|
||||
|
||||
┌─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┐
|
||||
│ Exit Code git_remote Command │
|
||||
│─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────│
|
||||
│ 0 https://github.com/ddworken/hishtory hishtory config-set displayed-columns 'Exit Code' git_remote Command │
|
||||
│ 0 echo bar │
|
||||
│ 0 cd / │
|
||||
│ 0 https://github.com/ddworken/hishtory echo foo │
|
||||
│ 0 https://github.com/ddworken/hishtory hishtory config-add custom-column git_remote '(git remote -v 2>/dev/null | grep origin 1>/dev/null ) && git remote get-url origin || … │
|
||||
│ 0 echo baz │
|
||||
│ 0 cd / │
|
||||
│ 0 echo $FOOBAR world │
|
||||
│ 0 export FOOBAR='hello' │
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
└─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┘
|
3
client/lib/goldens/testDisplayTable-customColumns
Normal file
3
client/lib/goldens/testDisplayTable-customColumns
Normal file
@@ -0,0 +1,3 @@
|
||||
Hostname Command
|
||||
localhost table_cmd2
|
||||
localhost table_cmd1
|
3
client/lib/goldens/testDisplayTable-customColumns-2
Normal file
3
client/lib/goldens/testDisplayTable-customColumns-2
Normal file
@@ -0,0 +1,3 @@
|
||||
Hostname Exit Code Command
|
||||
localhost 3 table_cmd2
|
||||
localhost 2 table_cmd1
|
3
client/lib/goldens/testDisplayTable-customColumns-3
Normal file
3
client/lib/goldens/testDisplayTable-customColumns-3
Normal file
@@ -0,0 +1,3 @@
|
||||
Hostname Exit Code Command CWD
|
||||
localhost 3 table_cmd2 ~/foo/
|
||||
localhost 2 table_cmd1 /tmp/
|
@@ -0,0 +1,7 @@
|
||||
Hostname Exit Code Command CWD
|
||||
localhost 2 while : /tmp/
|
||||
do
|
||||
ls /table/
|
||||
done
|
||||
localhost 3 table_cmd2 ~/foo/
|
||||
localhost 2 table_cmd1 /tmp/
|
@@ -0,0 +1,9 @@
|
||||
Hostname Exit Code Command CWD foo
|
||||
ghaction-runner-hostname 0 echo table-2 / aaaaaaaaaaaaa
|
||||
ghaction-runner-hostname 0 echo table-1 / aaaaaaaaaaaaa
|
||||
localhost 2 while : /tmp/
|
||||
do
|
||||
ls /table/
|
||||
done
|
||||
localhost 3 table_cmd2 ~/foo/
|
||||
localhost 2 table_cmd1 /tmp/
|
3
client/lib/goldens/testDisplayTable-defaultColumns
Normal file
3
client/lib/goldens/testDisplayTable-defaultColumns
Normal file
@@ -0,0 +1,3 @@
|
||||
Hostname CWD Timestamp Runtime Exit Code Command
|
||||
localhost ~/foo/ Apr 16 2022 01:03:16 PDT 24s 3 table_cmd2
|
||||
localhost /tmp/ Apr 16 2022 01:03:06 PDT 4s 2 table_cmd1
|
38
client/lib/goldens/testIntegrationWithNewDevice-bash
Normal file
38
client/lib/goldens/testIntegrationWithNewDevice-bash
Normal file
@@ -0,0 +1,38 @@
|
||||
set -emo pipefail
|
||||
hishtory status
|
||||
set -emo pipefail
|
||||
hishtory query
|
||||
ls /a
|
||||
ls /bar
|
||||
ls /foo
|
||||
echo foo
|
||||
echo bar
|
||||
hishtory enable
|
||||
echo thisisrecorded
|
||||
set -emo pipefail
|
||||
hishtory query
|
||||
set -emo pipefail
|
||||
hishtory query foo
|
||||
echo hello | grep complex | sed s/h/i/g; echo baz && echo "fo 'o" # mycommand
|
||||
set -emo pipefail
|
||||
hishtory query complex
|
||||
set -emo pipefail
|
||||
hishtory query
|
||||
set -emo pipefail
|
||||
echo mynewcommand
|
||||
set -emo pipefail
|
||||
hishtory query
|
||||
set -emo pipefail
|
||||
hishtory query
|
||||
set -emo pipefail
|
||||
echo mynewercommand
|
||||
set -emo pipefail
|
||||
hishtory query
|
||||
othercomputer
|
||||
set -emo pipefail
|
||||
hishtory query
|
||||
set -emo pipefail
|
||||
hishtory reupload
|
||||
set -emo pipefail
|
||||
hishtory export | grep -v pipefail | grep -v '/tmp/client install'
|
||||
set -emo pipefail
|
24
client/lib/goldens/testIntegrationWithNewDevice-tablebash
Normal file
24
client/lib/goldens/testIntegrationWithNewDevice-tablebash
Normal file
@@ -0,0 +1,24 @@
|
||||
Hostname Exit Code Command
|
||||
ghaction-runner-hostname 0 set -emo pipefail
|
||||
ghaction-runner-hostname 0 hishtory config-set displayed-columns Hostname 'Exit Code' Command
|
||||
ghaction-runner-hostname 0 set -emo pipefail
|
||||
ghaction-runner-hostname 0 set -emo pipefail
|
||||
ghaction-runner-hostname 0 hishtory export | grep -v pipefail | grep -v '/tmp/client install'
|
||||
ghaction-runner-hostname 0 set -emo pipefail
|
||||
ghaction-runner-hostname 0 hishtory reupload
|
||||
ghaction-runner-hostname 0 set -emo pipefail
|
||||
ghaction-runner-hostname 0 hishtory query
|
||||
ghaction-runner-hostname 0 set -emo pipefail
|
||||
localhost 2 othercomputer
|
||||
ghaction-runner-hostname 0 hishtory query
|
||||
ghaction-runner-hostname 0 set -emo pipefail
|
||||
ghaction-runner-hostname 0 echo mynewercommand
|
||||
ghaction-runner-hostname 0 set -emo pipefail
|
||||
ghaction-runner-hostname 0 hishtory query
|
||||
ghaction-runner-hostname 0 set -emo pipefail
|
||||
ghaction-runner-hostname 0 hishtory query
|
||||
ghaction-runner-hostname 0 set -emo pipefail
|
||||
ghaction-runner-hostname 0 echo mynewcommand
|
||||
ghaction-runner-hostname 0 set -emo pipefail
|
||||
ghaction-runner-hostname 0 hishtory query
|
||||
ghaction-runner-hostname 0 set -emo pipefail
|
24
client/lib/goldens/testIntegrationWithNewDevice-tablezsh
Normal file
24
client/lib/goldens/testIntegrationWithNewDevice-tablezsh
Normal file
@@ -0,0 +1,24 @@
|
||||
Hostname Exit Code Command
|
||||
ghaction-runner-hostname 0 set -eo pipefail
|
||||
ghaction-runner-hostname 0 hishtory config-set displayed-columns Hostname 'Exit Code' Command
|
||||
ghaction-runner-hostname 0 set -eo pipefail
|
||||
ghaction-runner-hostname 0 set -eo pipefail
|
||||
ghaction-runner-hostname 0 hishtory export | grep -v pipefail | grep -v '/tmp/client install'
|
||||
ghaction-runner-hostname 0 set -eo pipefail
|
||||
ghaction-runner-hostname 0 hishtory reupload
|
||||
ghaction-runner-hostname 0 set -eo pipefail
|
||||
ghaction-runner-hostname 0 hishtory query
|
||||
ghaction-runner-hostname 0 set -eo pipefail
|
||||
localhost 2 othercomputer
|
||||
ghaction-runner-hostname 0 hishtory query
|
||||
ghaction-runner-hostname 0 set -eo pipefail
|
||||
ghaction-runner-hostname 0 echo mynewercommand
|
||||
ghaction-runner-hostname 0 set -eo pipefail
|
||||
ghaction-runner-hostname 0 hishtory query
|
||||
ghaction-runner-hostname 0 set -eo pipefail
|
||||
ghaction-runner-hostname 0 hishtory query
|
||||
ghaction-runner-hostname 0 set -eo pipefail
|
||||
ghaction-runner-hostname 0 echo mynewcommand
|
||||
ghaction-runner-hostname 0 set -eo pipefail
|
||||
ghaction-runner-hostname 0 hishtory query
|
||||
ghaction-runner-hostname 0 set -eo pipefail
|
38
client/lib/goldens/testIntegrationWithNewDevice-zsh
Normal file
38
client/lib/goldens/testIntegrationWithNewDevice-zsh
Normal file
@@ -0,0 +1,38 @@
|
||||
set -eo pipefail
|
||||
hishtory status
|
||||
set -eo pipefail
|
||||
hishtory query
|
||||
ls /a
|
||||
ls /bar
|
||||
ls /foo
|
||||
echo foo
|
||||
echo bar
|
||||
hishtory enable
|
||||
echo thisisrecorded
|
||||
set -eo pipefail
|
||||
hishtory query
|
||||
set -eo pipefail
|
||||
hishtory query foo
|
||||
echo hello | grep complex | sed s/h/i/g; echo baz && echo "fo 'o" # mycommand
|
||||
set -eo pipefail
|
||||
hishtory query complex
|
||||
set -eo pipefail
|
||||
hishtory query
|
||||
set -eo pipefail
|
||||
echo mynewcommand
|
||||
set -eo pipefail
|
||||
hishtory query
|
||||
set -eo pipefail
|
||||
hishtory query
|
||||
set -eo pipefail
|
||||
echo mynewercommand
|
||||
set -eo pipefail
|
||||
hishtory query
|
||||
othercomputer
|
||||
set -eo pipefail
|
||||
hishtory query
|
||||
set -eo pipefail
|
||||
hishtory reupload
|
||||
set -eo pipefail
|
||||
hishtory export | grep -v pipefail | grep -v '/tmp/client install'
|
||||
set -eo pipefail
|
@@ -0,0 +1,7 @@
|
||||
echo foo
|
||||
echo foo
|
||||
echo baz
|
||||
echo baz
|
||||
echo foo
|
||||
hishtory config-set displayed-columns 'Exit Code' Command
|
||||
hishtory config-set filter-duplicate-commands true
|
6
client/lib/goldens/testRemoveDuplicateRows-enabled-query
Normal file
6
client/lib/goldens/testRemoveDuplicateRows-enabled-query
Normal file
@@ -0,0 +1,6 @@
|
||||
Exit Code Command
|
||||
0 hishtory config-set filter-duplicate-commands true
|
||||
0 hishtory config-set displayed-columns 'Exit Code' Command
|
||||
0 echo foo
|
||||
0 echo baz
|
||||
0 echo foo
|
30
client/lib/goldens/testRemoveDuplicateRows-enabled-tquery
Normal file
30
client/lib/goldens/testRemoveDuplicateRows-enabled-tquery
Normal file
@@ -0,0 +1,30 @@
|
||||
-pipefail
|
||||
|
||||
|
||||
|
||||
Search Query: > -pipefail
|
||||
|
||||
┌───────────────────────────────────────────────────────────────────────────┐
|
||||
│ Exit Code Command │
|
||||
│───────────────────────────────────────────────────────────────────────────│
|
||||
│ 0 hishtory config-set filter-duplicate-commands true │
|
||||
│ 0 hishtory config-set displayed-columns 'Exit Code' Command │
|
||||
│ 0 echo foo │
|
||||
│ 0 echo baz │
|
||||
│ 0 echo foo │
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
└───────────────────────────────────────────────────────────────────────────┘
|
5
client/lib/goldens/testRemoveDuplicateRows-export
Normal file
5
client/lib/goldens/testRemoveDuplicateRows-export
Normal file
@@ -0,0 +1,5 @@
|
||||
echo foo
|
||||
echo foo
|
||||
echo baz
|
||||
echo baz
|
||||
echo foo
|
7
client/lib/goldens/testRemoveDuplicateRows-query
Normal file
7
client/lib/goldens/testRemoveDuplicateRows-query
Normal file
@@ -0,0 +1,7 @@
|
||||
Exit Code Command
|
||||
0 hishtory config-set displayed-columns 'Exit Code' Command
|
||||
0 echo foo
|
||||
0 echo baz
|
||||
0 echo baz
|
||||
0 echo foo
|
||||
0 echo foo
|
30
client/lib/goldens/testRemoveDuplicateRows-tquery
Normal file
30
client/lib/goldens/testRemoveDuplicateRows-tquery
Normal file
@@ -0,0 +1,30 @@
|
||||
-pipefail
|
||||
|
||||
|
||||
|
||||
Search Query: > -pipefail
|
||||
|
||||
┌───────────────────────────────────────────────────────────────────────────┐
|
||||
│ Exit Code Command │
|
||||
│───────────────────────────────────────────────────────────────────────────│
|
||||
│ 0 hishtory config-set displayed-columns 'Exit Code' Command │
|
||||
│ 0 echo foo │
|
||||
│ 0 echo baz │
|
||||
│ 0 echo baz │
|
||||
│ 0 echo foo │
|
||||
│ 0 echo foo │
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
└───────────────────────────────────────────────────────────────────────────┘
|
2
client/lib/goldens/testUninstall-post-uninstall
Normal file
2
client/lib/goldens/testUninstall-post-uninstall
Normal file
@@ -0,0 +1,2 @@
|
||||
foo
|
||||
bar
|
8
client/lib/goldens/testUninstall-post-uninstall-bash
Normal file
8
client/lib/goldens/testUninstall-post-uninstall-bash
Normal file
@@ -0,0 +1,8 @@
|
||||
bash-5.2$ source /Users/david/.bashrc
|
||||
bash-5.2$ echo foo
|
||||
foo
|
||||
bash-5.2$ hishtory
|
||||
bash: hishtory: command not found
|
||||
bash-5.2$ echo bar
|
||||
bar
|
||||
bash-5.2$
|
7
client/lib/goldens/testUninstall-post-uninstall-zsh
Normal file
7
client/lib/goldens/testUninstall-post-uninstall-zsh
Normal file
@@ -0,0 +1,7 @@
|
||||
david@Davids-MacBook-Air hishtory % echo foo
|
||||
foo
|
||||
david@Davids-MacBook-Air hishtory % hishtory
|
||||
zsh: command not found: hishtory
|
||||
david@Davids-MacBook-Air hishtory % echo bar
|
||||
bar
|
||||
david@Davids-MacBook-Air hishtory %
|
2
client/lib/goldens/testUninstall-recorded
Normal file
2
client/lib/goldens/testUninstall-recorded
Normal file
@@ -0,0 +1,2 @@
|
||||
echo foo
|
||||
echo baz
|
1
client/lib/goldens/testUninstall-uninstall
Normal file
1
client/lib/goldens/testUninstall-uninstall
Normal file
@@ -0,0 +1 @@
|
||||
Are you sure you want to uninstall hiSHtory and delete all locally saved history data [y/N]Successfully uninstalled hishtory, please restart your terminal...
|
1700
client/lib/lib.go
Normal file
1700
client/lib/lib.go
Normal file
File diff suppressed because it is too large
Load Diff
435
client/lib/lib_test.go
Normal file
435
client/lib/lib_test.go
Normal file
@@ -0,0 +1,435 @@
|
||||
package lib
|
||||
|
||||
import (
|
||||
"os"
|
||||
"os/user"
|
||||
"path"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/ddworken/hishtory/client/data"
|
||||
"github.com/ddworken/hishtory/client/hctx"
|
||||
"github.com/ddworken/hishtory/shared/testutils"
|
||||
)
|
||||
|
||||
func TestSetup(t *testing.T) {
|
||||
defer testutils.BackupAndRestore(t)()
|
||||
defer testutils.RunTestServer()()
|
||||
|
||||
homedir, err := os.UserHomeDir()
|
||||
testutils.Check(t, err)
|
||||
if _, err := os.Stat(path.Join(homedir, data.HISHTORY_PATH, data.CONFIG_PATH)); err == nil {
|
||||
t.Fatalf("hishtory secret file already exists!")
|
||||
}
|
||||
testutils.Check(t, Setup([]string{}))
|
||||
if _, err := os.Stat(path.Join(homedir, data.HISHTORY_PATH, data.CONFIG_PATH)); err != nil {
|
||||
t.Fatalf("hishtory secret file does not exist after Setup()!")
|
||||
}
|
||||
data, err := os.ReadFile(path.Join(homedir, data.HISHTORY_PATH, data.CONFIG_PATH))
|
||||
testutils.Check(t, err)
|
||||
if len(data) < 10 {
|
||||
t.Fatalf("hishtory secret has unexpected length: %d", len(data))
|
||||
}
|
||||
config := hctx.GetConf(hctx.MakeContext())
|
||||
if config.IsOffline != false {
|
||||
t.Fatalf("hishtory config should have been offline")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetupOffline(t *testing.T) {
|
||||
defer testutils.BackupAndRestore(t)()
|
||||
defer testutils.RunTestServer()()
|
||||
|
||||
homedir, err := os.UserHomeDir()
|
||||
testutils.Check(t, err)
|
||||
if _, err := os.Stat(path.Join(homedir, data.HISHTORY_PATH, data.CONFIG_PATH)); err == nil {
|
||||
t.Fatalf("hishtory secret file already exists!")
|
||||
}
|
||||
testutils.Check(t, Setup([]string{"", "", "--offline"}))
|
||||
if _, err := os.Stat(path.Join(homedir, data.HISHTORY_PATH, data.CONFIG_PATH)); err != nil {
|
||||
t.Fatalf("hishtory secret file does not exist after Setup()!")
|
||||
}
|
||||
data, err := os.ReadFile(path.Join(homedir, data.HISHTORY_PATH, data.CONFIG_PATH))
|
||||
testutils.Check(t, err)
|
||||
if len(data) < 10 {
|
||||
t.Fatalf("hishtory secret has unexpected length: %d", len(data))
|
||||
}
|
||||
config := hctx.GetConf(hctx.MakeContext())
|
||||
if config.IsOffline != true {
|
||||
t.Fatalf("hishtory config should have been offline, actual=%#v", string(data))
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildHistoryEntry(t *testing.T) {
|
||||
defer testutils.BackupAndRestore(t)()
|
||||
defer testutils.RunTestServer()()
|
||||
testutils.Check(t, Setup([]string{}))
|
||||
|
||||
// Test building an actual entry for bash
|
||||
entry, err := BuildHistoryEntry(hctx.MakeContext(), []string{"unused", "saveHistoryEntry", "bash", "120", " 123 ls /foo ", "1641774958"})
|
||||
testutils.Check(t, err)
|
||||
if entry.ExitCode != 120 {
|
||||
t.Fatalf("history entry has unexpected exit code: %v", entry.ExitCode)
|
||||
}
|
||||
user, err := user.Current()
|
||||
if err != nil {
|
||||
t.Fatalf("failed to retrieve user: %v", err)
|
||||
}
|
||||
if entry.LocalUsername != user.Username {
|
||||
t.Fatalf("history entry has unexpected user name: %v", entry.LocalUsername)
|
||||
}
|
||||
if !strings.HasPrefix(entry.CurrentWorkingDirectory, "/") && !strings.HasPrefix(entry.CurrentWorkingDirectory, "~/") {
|
||||
t.Fatalf("history entry has unexpected cwd: %v", entry.CurrentWorkingDirectory)
|
||||
}
|
||||
if !strings.HasPrefix(entry.HomeDirectory, "/") {
|
||||
t.Fatalf("history entry has unexpected home directory: %v", entry.HomeDirectory)
|
||||
}
|
||||
if entry.Command != "ls /foo" {
|
||||
t.Fatalf("history entry has unexpected command: %v", entry.Command)
|
||||
}
|
||||
if !strings.HasPrefix(entry.StartTime.Format(time.RFC3339), "2022-01-09T") && !strings.HasPrefix(entry.StartTime.Format(time.RFC3339), "2022-01-10T") {
|
||||
t.Fatalf("history entry has incorrect date in the start time: %v", entry.StartTime.Format(time.RFC3339))
|
||||
}
|
||||
if entry.StartTime.Unix() != 1641774958 {
|
||||
t.Fatalf("history entry has incorrect Unix time in the start time: %v", entry.StartTime.Unix())
|
||||
}
|
||||
|
||||
// Test building an entry for zsh
|
||||
entry, err = BuildHistoryEntry(hctx.MakeContext(), []string{"unused", "saveHistoryEntry", "zsh", "120", "ls /foo\n", "1641774958"})
|
||||
testutils.Check(t, err)
|
||||
if entry.ExitCode != 120 {
|
||||
t.Fatalf("history entry has unexpected exit code: %v", entry.ExitCode)
|
||||
}
|
||||
if entry.LocalUsername != user.Username {
|
||||
t.Fatalf("history entry has unexpected user name: %v", entry.LocalUsername)
|
||||
}
|
||||
if !strings.HasPrefix(entry.CurrentWorkingDirectory, "/") && !strings.HasPrefix(entry.CurrentWorkingDirectory, "~/") {
|
||||
t.Fatalf("history entry has unexpected cwd: %v", entry.CurrentWorkingDirectory)
|
||||
}
|
||||
if !strings.HasPrefix(entry.HomeDirectory, "/") {
|
||||
t.Fatalf("history entry has unexpected home directory: %v", entry.HomeDirectory)
|
||||
}
|
||||
if entry.Command != "ls /foo" {
|
||||
t.Fatalf("history entry has unexpected command: %v", entry.Command)
|
||||
}
|
||||
if !strings.HasPrefix(entry.StartTime.Format(time.RFC3339), "2022-01-09T") && !strings.HasPrefix(entry.StartTime.Format(time.RFC3339), "2022-01-10T") {
|
||||
t.Fatalf("history entry has incorrect date in the start time: %v", entry.StartTime.Format(time.RFC3339))
|
||||
}
|
||||
if entry.StartTime.Unix() != 1641774958 {
|
||||
t.Fatalf("history entry has incorrect Unix time in the start time: %v", entry.StartTime.Unix())
|
||||
}
|
||||
|
||||
// Test building an entry for fish
|
||||
entry, err = BuildHistoryEntry(hctx.MakeContext(), []string{"unused", "saveHistoryEntry", "fish", "120", "ls /foo\n", "1641774958"})
|
||||
testutils.Check(t, err)
|
||||
if entry.ExitCode != 120 {
|
||||
t.Fatalf("history entry has unexpected exit code: %v", entry.ExitCode)
|
||||
}
|
||||
if entry.LocalUsername != user.Username {
|
||||
t.Fatalf("history entry has unexpected user name: %v", entry.LocalUsername)
|
||||
}
|
||||
if !strings.HasPrefix(entry.CurrentWorkingDirectory, "/") && !strings.HasPrefix(entry.CurrentWorkingDirectory, "~/") {
|
||||
t.Fatalf("history entry has unexpected cwd: %v", entry.CurrentWorkingDirectory)
|
||||
}
|
||||
if !strings.HasPrefix(entry.HomeDirectory, "/") {
|
||||
t.Fatalf("history entry has unexpected home directory: %v", entry.HomeDirectory)
|
||||
}
|
||||
if entry.Command != "ls /foo" {
|
||||
t.Fatalf("history entry has unexpected command: %v", entry.Command)
|
||||
}
|
||||
if !strings.HasPrefix(entry.StartTime.Format(time.RFC3339), "2022-01-09T") && !strings.HasPrefix(entry.StartTime.Format(time.RFC3339), "2022-01-10T") {
|
||||
t.Fatalf("history entry has incorrect date in the start time: %v", entry.StartTime.Format(time.RFC3339))
|
||||
}
|
||||
if entry.StartTime.Unix() != 1641774958 {
|
||||
t.Fatalf("history entry has incorrect Unix time in the start time: %v", entry.StartTime.Unix())
|
||||
}
|
||||
|
||||
// Test building an entry that is empty, and thus not saved
|
||||
entry, err = BuildHistoryEntry(hctx.MakeContext(), []string{"unused", "saveHistoryEntry", "zsh", "120", " \n", "1641774958"})
|
||||
testutils.Check(t, err)
|
||||
if entry != nil {
|
||||
t.Fatalf("expected history entry to be nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildHistoryEntryWithTimestampStripping(t *testing.T) {
|
||||
defer testutils.BackupAndRestoreEnv("HISTTIMEFORMAT")()
|
||||
defer testutils.BackupAndRestore(t)()
|
||||
defer testutils.RunTestServer()()
|
||||
testutils.Check(t, Setup([]string{}))
|
||||
|
||||
testcases := []struct {
|
||||
input, histtimeformat, expectedCommand string
|
||||
}{
|
||||
{" 123 ls /foo ", "", "ls /foo"},
|
||||
{" 2389 [2022-09-28 04:38:32 +0000] echo", "", "[2022-09-28 04:38:32 +0000] echo"},
|
||||
{" 2389 [2022-09-28 04:38:32 +0000] echo", "[%F %T %z] ", "echo"},
|
||||
}
|
||||
for _, tc := range testcases {
|
||||
conf := hctx.GetConf(hctx.MakeContext())
|
||||
conf.LastSavedHistoryLine = ""
|
||||
testutils.Check(t, hctx.SetConfig(conf))
|
||||
|
||||
os.Setenv("HISTTIMEFORMAT", tc.histtimeformat)
|
||||
entry, err := BuildHistoryEntry(hctx.MakeContext(), []string{"unused", "saveHistoryEntry", "bash", "120", tc.input, "1641774958"})
|
||||
testutils.Check(t, err)
|
||||
if entry == nil {
|
||||
t.Fatalf("entry is unexpectedly nil")
|
||||
}
|
||||
if entry.Command != tc.expectedCommand {
|
||||
t.Fatalf("BuildHistoryEntry(%#v) returned %#v (expected=%#v)", tc.input, entry.Command, tc.expectedCommand)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPersist(t *testing.T) {
|
||||
defer testutils.BackupAndRestore(t)()
|
||||
testutils.Check(t, hctx.InitConfig())
|
||||
db := hctx.GetDb(hctx.MakeContext())
|
||||
|
||||
entry := testutils.MakeFakeHistoryEntry("ls ~/")
|
||||
db.Create(entry)
|
||||
var historyEntries []*data.HistoryEntry
|
||||
result := db.Find(&historyEntries)
|
||||
testutils.Check(t, result.Error)
|
||||
if len(historyEntries) != 1 {
|
||||
t.Fatalf("DB has %d entries, expected 1!", len(historyEntries))
|
||||
}
|
||||
dbEntry := historyEntries[0]
|
||||
if !data.EntryEquals(entry, *dbEntry) {
|
||||
t.Fatalf("DB data is different than input! \ndb =%#v \ninput=%#v", *dbEntry, entry)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSearch(t *testing.T) {
|
||||
defer testutils.BackupAndRestore(t)()
|
||||
testutils.Check(t, hctx.InitConfig())
|
||||
ctx := hctx.MakeContext()
|
||||
db := hctx.GetDb(ctx)
|
||||
|
||||
// Insert data
|
||||
entry1 := testutils.MakeFakeHistoryEntry("ls /foo")
|
||||
db.Create(entry1)
|
||||
entry2 := testutils.MakeFakeHistoryEntry("ls /bar")
|
||||
db.Create(entry2)
|
||||
|
||||
// Search for data
|
||||
results, err := Search(ctx, db, "ls", 5)
|
||||
testutils.Check(t, err)
|
||||
if len(results) != 2 {
|
||||
t.Fatalf("Search() returned %d results, expected 2!", len(results))
|
||||
}
|
||||
if !data.EntryEquals(*results[0], entry2) {
|
||||
t.Fatalf("Search()[0]=%#v, expected: %#v", results[0], entry2)
|
||||
}
|
||||
if !data.EntryEquals(*results[1], entry1) {
|
||||
t.Fatalf("Search()[0]=%#v, expected: %#v", results[1], entry1)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAddToDbIfNew(t *testing.T) {
|
||||
// Set up
|
||||
defer testutils.BackupAndRestore(t)()
|
||||
testutils.Check(t, hctx.InitConfig())
|
||||
db := hctx.GetDb(hctx.MakeContext())
|
||||
|
||||
// Add duplicate entries
|
||||
entry1 := testutils.MakeFakeHistoryEntry("ls /foo")
|
||||
AddToDbIfNew(db, entry1)
|
||||
AddToDbIfNew(db, entry1)
|
||||
entry2 := testutils.MakeFakeHistoryEntry("ls /foo")
|
||||
AddToDbIfNew(db, entry2)
|
||||
AddToDbIfNew(db, entry2)
|
||||
AddToDbIfNew(db, entry1)
|
||||
|
||||
// Check there should only be two entries
|
||||
var entries []data.HistoryEntry
|
||||
result := db.Find(&entries)
|
||||
if result.Error != nil {
|
||||
t.Fatal(result.Error)
|
||||
}
|
||||
if len(entries) != 2 {
|
||||
t.Fatalf("entries has an incorrect length: %d", len(entries))
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseCrossPlatformInt(t *testing.T) {
|
||||
res, err := parseCrossPlatformInt("123")
|
||||
testutils.Check(t, err)
|
||||
if res != 123 {
|
||||
t.Fatalf("failed to parse cross platform int %d", res)
|
||||
}
|
||||
res, err = parseCrossPlatformInt("123N")
|
||||
testutils.Check(t, err)
|
||||
if res != 123 {
|
||||
t.Fatalf("failed to parse cross platform int %d", res)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildRegexFromTimeFormat(t *testing.T) {
|
||||
testcases := []struct {
|
||||
formatString, regex string
|
||||
}{
|
||||
{"%Y ", "[0-9]{4} "},
|
||||
{"%F %T ", "[0-9]{4}-[0-9]{2}-[0-9]{2} [0-9]{2}:[0-9]{2}:[0-9]{2} "},
|
||||
{"%F%T", "[0-9]{4}-[0-9]{2}-[0-9]{2}[0-9]{2}:[0-9]{2}:[0-9]{2}"},
|
||||
{"%%", "%"},
|
||||
{"%%%%", "%%"},
|
||||
{"%%%Y", "%[0-9]{4}"},
|
||||
{"%%%F%T", "%[0-9]{4}-[0-9]{2}-[0-9]{2}[0-9]{2}:[0-9]{2}:[0-9]{2}"},
|
||||
}
|
||||
|
||||
for _, tc := range testcases {
|
||||
if regex := buildRegexFromTimeFormat(tc.formatString); regex != tc.regex {
|
||||
t.Fatalf("building a regex for %#v returned %#v (expected=%#v)", tc.formatString, regex, tc.regex)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetLastCommand(t *testing.T) {
|
||||
testcases := []struct {
|
||||
input, expectedOutput string
|
||||
}{
|
||||
{" 0 ls", "ls"},
|
||||
{" 33 ls", "ls"},
|
||||
{" 33 ls --aaaa foo bar ", "ls --aaaa foo bar"},
|
||||
{" 2389 [2022-09-28 04:38:32 +0000] echo", "[2022-09-28 04:38:32 +0000] echo"},
|
||||
}
|
||||
for _, tc := range testcases {
|
||||
actualOutput, err := getLastCommand(tc.input)
|
||||
testutils.Check(t, err)
|
||||
if actualOutput != tc.expectedOutput {
|
||||
t.Fatalf("getLastCommand(%#v) returned %#v (expected=%#v)", tc.input, actualOutput, tc.expectedOutput)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMaybeSkipBashHistTimePrefix(t *testing.T) {
|
||||
defer testutils.BackupAndRestoreEnv("HISTTIMEFORMAT")()
|
||||
|
||||
testcases := []struct {
|
||||
env, cmdLine, expected string
|
||||
}{
|
||||
{"%F %T ", "2019-07-12 13:02:31 sudo apt update", "sudo apt update"},
|
||||
{"%F %T ", "2019-07-12 13:02:31 ls a b", "ls a b"},
|
||||
{"%F %T ", "2019-07-12 13:02:31 ls a ", "ls a "},
|
||||
{"%F %T ", "2019-07-12 13:02:31 ls a", "ls a"},
|
||||
{"%F %T ", "2019-07-12 13:02:31 ls", "ls"},
|
||||
{"%F %T ", "2019-07-12 13:02:31 ls -Slah", "ls -Slah"},
|
||||
{"%F ", "2019-07-12 ls -Slah", "ls -Slah"},
|
||||
{"%F ", "2019-07-12 ls -Slah", "ls -Slah"},
|
||||
{"%Y", "2019ls -Slah", "ls -Slah"},
|
||||
{"%Y%Y", "20192020ls -Slah", "ls -Slah"},
|
||||
{"%Y%Y", "20192020ls -Slah20192020", "ls -Slah20192020"},
|
||||
{"", "ls -Slah", "ls -Slah"},
|
||||
{"[%F %T] ", "[2019-07-12 13:02:31] sudo apt update", "sudo apt update"},
|
||||
{"[%F a %T] ", "[2019-07-12 a 13:02:31] sudo apt update", "sudo apt update"},
|
||||
{"aaa ", "aaa sudo apt update", "sudo apt update"},
|
||||
{"%c ", "Sun Aug 19 02:56:02 2012 sudo apt update", "sudo apt update"},
|
||||
{"%c ", "Sun Aug 19 02:56:02 2012 ls", "ls"},
|
||||
{"[%c] ", "[Sun Aug 19 02:56:02 2012] ls", "ls"},
|
||||
{"[%c %t] ", "[Sun Aug 19 02:56:02 2012 ] ls", "ls"},
|
||||
{"[%c %t]", "[Sun Aug 19 02:56:02 2012 ]ls", "ls"},
|
||||
{"[%c %t]", "[Sun Aug 19 02:56:02 2012 ]foo", "foo"},
|
||||
{"[%c %t", "[Sun Aug 19 02:56:02 2012 foo", "foo"},
|
||||
{"[%F %T %z]", "[2022-09-28 04:17:06 +0000]foo", "foo"},
|
||||
{"[%F %T %z] ", "[2022-09-28 04:17:06 +0000] foo", "foo"},
|
||||
}
|
||||
|
||||
for _, tc := range testcases {
|
||||
os.Setenv("HISTTIMEFORMAT", tc.env)
|
||||
stripped, err := maybeSkipBashHistTimePrefix(tc.cmdLine)
|
||||
testutils.Check(t, err)
|
||||
if stripped != tc.expected {
|
||||
t.Fatalf("skipping the time prefix returned %#v (expected=%#v for %#v)", stripped, tc.expected, tc.cmdLine)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestChunks(t *testing.T) {
|
||||
testcases := []struct {
|
||||
input []int
|
||||
chunkSize int
|
||||
output [][]int
|
||||
}{
|
||||
{[]int{1, 2, 3, 4, 5}, 2, [][]int{{1, 2}, {3, 4}, {5}}},
|
||||
{[]int{1, 2, 3, 4, 5}, 3, [][]int{{1, 2, 3}, {4, 5}}},
|
||||
{[]int{1, 2, 3, 4, 5}, 1, [][]int{{1}, {2}, {3}, {4}, {5}}},
|
||||
{[]int{1, 2, 3, 4, 5}, 4, [][]int{{1, 2, 3, 4}, {5}}},
|
||||
}
|
||||
for _, tc := range testcases {
|
||||
actual := chunks(tc.input, tc.chunkSize)
|
||||
if !reflect.DeepEqual(actual, tc.output) {
|
||||
t.Fatal("chunks failure")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestZshWeirdness(t *testing.T) {
|
||||
testcases := []struct {
|
||||
input string
|
||||
output string
|
||||
}{
|
||||
{": 1666062975:0;bash", "bash"},
|
||||
{": 16660:0;ls", "ls"},
|
||||
{"ls", "ls"},
|
||||
{"0", "0"},
|
||||
{"hgffddxsdsrzsz xddfgdxfdv gdfc ghcvhgfcfg vgv", "hgffddxsdsrzsz xddfgdxfdv gdfc ghcvhgfcfg vgv"},
|
||||
}
|
||||
for _, tc := range testcases {
|
||||
actual := stripZshWeirdness(tc.input)
|
||||
if !reflect.DeepEqual(actual, tc.output) {
|
||||
t.Fatalf("weirdness failure for %#v", tc.input)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseTimeGenerously(t *testing.T) {
|
||||
ts, err := parseTimeGenerously("2006-01-02T15:04:00-08:00")
|
||||
testutils.Check(t, err)
|
||||
if ts.Unix() != 1136243040 {
|
||||
t.Fatalf("parsed time incorrectly: %d", ts.Unix())
|
||||
}
|
||||
ts, err = parseTimeGenerously("2006-01-02 T15:04:00 -08:00")
|
||||
testutils.Check(t, err)
|
||||
if ts.Unix() != 1136243040 {
|
||||
t.Fatalf("parsed time incorrectly: %d", ts.Unix())
|
||||
}
|
||||
ts, err = parseTimeGenerously("2006-01-02_T15:04:00_-08:00")
|
||||
testutils.Check(t, err)
|
||||
if ts.Unix() != 1136243040 {
|
||||
t.Fatalf("parsed time incorrectly: %d", ts.Unix())
|
||||
}
|
||||
ts, err = parseTimeGenerously("2006-01-02T15:04:00")
|
||||
testutils.Check(t, err)
|
||||
if ts.Year() != 2006 || ts.Month() != time.January || ts.Day() != 2 || ts.Hour() != 15 || ts.Minute() != 4 || ts.Second() != 0 {
|
||||
t.Fatalf("parsed time incorrectly: %d", ts.Unix())
|
||||
}
|
||||
ts, err = parseTimeGenerously("2006-01-02_T15:04:00")
|
||||
testutils.Check(t, err)
|
||||
if ts.Year() != 2006 || ts.Month() != time.January || ts.Day() != 2 || ts.Hour() != 15 || ts.Minute() != 4 || ts.Second() != 0 {
|
||||
t.Fatalf("parsed time incorrectly: %d", ts.Unix())
|
||||
}
|
||||
ts, err = parseTimeGenerously("2006-01-02_15:04:00")
|
||||
testutils.Check(t, err)
|
||||
if ts.Year() != 2006 || ts.Month() != time.January || ts.Day() != 2 || ts.Hour() != 15 || ts.Minute() != 4 || ts.Second() != 0 {
|
||||
t.Fatalf("parsed time incorrectly: %d", ts.Unix())
|
||||
}
|
||||
ts, err = parseTimeGenerously("2006-01-02T15:04")
|
||||
testutils.Check(t, err)
|
||||
if ts.Year() != 2006 || ts.Month() != time.January || ts.Day() != 2 || ts.Hour() != 15 || ts.Minute() != 4 || ts.Second() != 0 {
|
||||
t.Fatalf("parsed time incorrectly: %d", ts.Unix())
|
||||
}
|
||||
ts, err = parseTimeGenerously("2006-01-02_15:04")
|
||||
testutils.Check(t, err)
|
||||
if ts.Year() != 2006 || ts.Month() != time.January || ts.Day() != 2 || ts.Hour() != 15 || ts.Minute() != 4 || ts.Second() != 0 {
|
||||
t.Fatalf("parsed time incorrectly: %d", ts.Unix())
|
||||
}
|
||||
ts, err = parseTimeGenerously("2006-01-02")
|
||||
testutils.Check(t, err)
|
||||
if ts.Year() != 2006 || ts.Month() != time.January || ts.Day() != 2 || ts.Hour() != 0 || ts.Minute() != 0 || ts.Second() != 0 {
|
||||
t.Fatalf("parsed time incorrectly: %d", ts.Unix())
|
||||
}
|
||||
}
|
99
client/lib/slsa.go
Normal file
99
client/lib/slsa.go
Normal file
@@ -0,0 +1,99 @@
|
||||
package lib
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/slsa-framework/slsa-verifier/options"
|
||||
"github.com/slsa-framework/slsa-verifier/verifiers"
|
||||
)
|
||||
|
||||
func verify(ctx *context.Context, provenance []byte, artifactHash, source, branch, versionTag string) error {
|
||||
provenanceOpts := &options.ProvenanceOpts{
|
||||
ExpectedSourceURI: source,
|
||||
ExpectedBranch: &branch,
|
||||
ExpectedDigest: artifactHash,
|
||||
ExpectedVersionedTag: &versionTag,
|
||||
}
|
||||
builderOpts := &options.BuilderOpts{}
|
||||
_, _, err := verifiers.Verify(*ctx, provenance, artifactHash, provenanceOpts, builderOpts)
|
||||
return err
|
||||
}
|
||||
|
||||
func checkForDowngrade(currentVersionS, newVersionS string) error {
|
||||
currentVersion, err := strconv.Atoi(strings.TrimPrefix(currentVersionS, "v0."))
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to parse current version %#v", currentVersionS)
|
||||
}
|
||||
newVersion, err := strconv.Atoi(strings.TrimPrefix(newVersionS, "v0."))
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to parse updated version %#v", newVersionS)
|
||||
}
|
||||
if currentVersion > newVersion {
|
||||
return fmt.Errorf("failed to update because the new version (%#v) is a downgrade compared to the current version (%#v)", newVersionS, currentVersionS)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func verifyBinary(ctx *context.Context, binaryPath, attestationPath, versionTag string) error {
|
||||
if os.Getenv("HISHTORY_DISABLE_SLSA_ATTESTATION") == "true" {
|
||||
return nil
|
||||
}
|
||||
resp, err := ApiGet("/api/v1/slsa-status?newVersion=" + versionTag)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
if string(resp) != "OK" {
|
||||
fmt.Printf("SLSA verification is currently broken (%s), skipping SLSA validation...\n", string(resp))
|
||||
return nil
|
||||
}
|
||||
|
||||
if err := checkForDowngrade(Version, versionTag); err != nil && os.Getenv("HISHTORY_ALLOW_DOWNGRADE") == "true" {
|
||||
return err
|
||||
}
|
||||
|
||||
attestation, err := os.ReadFile(attestationPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to read attestation file: %v", err)
|
||||
}
|
||||
|
||||
hash, err := getFileHash(binaryPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return verify(ctx, attestation, hash, "github.com/ddworken/hishtory", "master", versionTag)
|
||||
}
|
||||
|
||||
func getFileHash(binaryPath string) (string, error) {
|
||||
binaryFile, err := os.Open(binaryPath)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to read binary for verification purposes: %v", err)
|
||||
}
|
||||
defer binaryFile.Close()
|
||||
|
||||
hasher := sha256.New()
|
||||
if _, err := io.Copy(hasher, binaryFile); err != nil {
|
||||
return "", fmt.Errorf("failed to hash binary: %v", err)
|
||||
}
|
||||
hash := hex.EncodeToString(hasher.Sum(nil))
|
||||
return hash, nil
|
||||
}
|
||||
|
||||
func handleSlsaFailure(srcErr error) error {
|
||||
fmt.Printf("\nFailed to verify SLSA provenance! This is likely due to a SLSA bug (SLSA is a brand new standard, and like all new things, has bugs). Ignoring this failure means falling back to the way most software does updates. Do you want to ignore this failure and update anyways? [y/N]")
|
||||
reader := bufio.NewReader(os.Stdin)
|
||||
resp, err := reader.ReadString('\n')
|
||||
if err == nil && strings.TrimSpace(resp) == "y" {
|
||||
fmt.Println("Proceeding with update...")
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("failed to verify SLSA provenance of the updated binary, aborting update (to bypass, set `export HISHTORY_DISABLE_SLSA_ATTESTATION=true`): %v", srcErr)
|
||||
}
|
451
client/lib/tui.go
Normal file
451
client/lib/tui.go
Normal file
@@ -0,0 +1,451 @@
|
||||
package lib
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
_ "embed" // for embedding config.sh
|
||||
|
||||
"github.com/charmbracelet/bubbles/key"
|
||||
"github.com/charmbracelet/bubbles/spinner"
|
||||
"github.com/charmbracelet/bubbles/table"
|
||||
"github.com/charmbracelet/bubbles/textinput"
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
"github.com/ddworken/hishtory/client/hctx"
|
||||
"github.com/muesli/termenv"
|
||||
"golang.org/x/term"
|
||||
)
|
||||
|
||||
const TABLE_HEIGHT = 20
|
||||
const PADDED_NUM_ENTRIES = TABLE_HEIGHT * 5
|
||||
|
||||
var selectedRow string = ""
|
||||
|
||||
var baseStyle = lipgloss.NewStyle().
|
||||
BorderStyle(lipgloss.NormalBorder()).
|
||||
BorderForeground(lipgloss.Color("240"))
|
||||
|
||||
type errMsg error
|
||||
|
||||
type model struct {
|
||||
// context
|
||||
ctx *context.Context
|
||||
|
||||
// Model for the loading spinner.
|
||||
spinner spinner.Model
|
||||
// Whether data is still loading and the spinner should still be displayed.
|
||||
isLoading bool
|
||||
|
||||
// Whether the TUI is quitting.
|
||||
quitting bool
|
||||
|
||||
// The table used for displaying search results.
|
||||
table table.Model
|
||||
// The number of entries in the table.
|
||||
numEntries int
|
||||
// Whether the user has hit enter to select an entry and the TUI is thus about to quit.
|
||||
selected bool
|
||||
|
||||
// The search box for the query
|
||||
queryInput textinput.Model
|
||||
// The query to run. Reset to nil after it was run.
|
||||
runQuery *string
|
||||
// The previous query that was run.
|
||||
lastQuery string
|
||||
|
||||
// Unrecoverable error.
|
||||
err error
|
||||
// An error while searching. Recoverable and displayed as a warning message.
|
||||
searchErr error
|
||||
// Whether the device is offline. If so, a warning will be displayed.
|
||||
isOffline bool
|
||||
|
||||
// A banner from the backend to be displayed. Generally an empty string.
|
||||
banner string
|
||||
}
|
||||
|
||||
type doneDownloadingMsg struct{}
|
||||
type offlineMsg struct{}
|
||||
type bannerMsg struct {
|
||||
banner string
|
||||
}
|
||||
|
||||
func initialModel(ctx *context.Context, t table.Model, initialQuery string, numEntries int) model {
|
||||
s := spinner.New()
|
||||
s.Spinner = spinner.Dot
|
||||
s.Style = lipgloss.NewStyle().Foreground(lipgloss.Color("205"))
|
||||
queryInput := textinput.New()
|
||||
queryInput.Placeholder = "ls"
|
||||
queryInput.Focus()
|
||||
queryInput.CharLimit = 156
|
||||
queryInput.Width = 50
|
||||
if initialQuery != "" {
|
||||
queryInput.SetValue(initialQuery)
|
||||
}
|
||||
return model{ctx: ctx, spinner: s, isLoading: true, table: t, runQuery: &initialQuery, queryInput: queryInput, numEntries: numEntries}
|
||||
}
|
||||
|
||||
func (m model) Init() tea.Cmd {
|
||||
return m.spinner.Tick
|
||||
}
|
||||
|
||||
func runQueryAndUpdateTable(m model, updateTable bool) model {
|
||||
if (m.runQuery != nil && *m.runQuery != m.lastQuery) || updateTable {
|
||||
if m.runQuery == nil {
|
||||
m.runQuery = &m.lastQuery
|
||||
}
|
||||
rows, numEntries, err := getRows(m.ctx, hctx.GetConf(m.ctx).DisplayedColumns, *m.runQuery, PADDED_NUM_ENTRIES)
|
||||
if err != nil {
|
||||
m.searchErr = err
|
||||
return m
|
||||
} else {
|
||||
m.searchErr = nil
|
||||
}
|
||||
m.numEntries = numEntries
|
||||
if updateTable {
|
||||
t, err := makeTable(m.ctx, rows)
|
||||
if err != nil {
|
||||
m.err = err
|
||||
return m
|
||||
}
|
||||
m.table = t
|
||||
}
|
||||
m.table.SetRows(rows)
|
||||
m.table.SetCursor(0)
|
||||
m.lastQuery = *m.runQuery
|
||||
m.runQuery = nil
|
||||
}
|
||||
if m.table.Cursor() >= m.numEntries {
|
||||
// Ensure that we can't scroll past the end of the table
|
||||
m.table.SetCursor(m.numEntries - 1)
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
switch msg := msg.(type) {
|
||||
case tea.KeyMsg:
|
||||
switch msg.String() {
|
||||
case "esc", "ctrl+c":
|
||||
m.quitting = true
|
||||
return m, tea.Quit
|
||||
case "enter":
|
||||
if m.numEntries != 0 {
|
||||
m.selected = true
|
||||
}
|
||||
return m, tea.Quit
|
||||
default:
|
||||
t, cmd1 := m.table.Update(msg)
|
||||
m.table = t
|
||||
if strings.HasPrefix(msg.String(), "alt+") {
|
||||
return m, tea.Batch(cmd1)
|
||||
}
|
||||
i, cmd2 := m.queryInput.Update(msg)
|
||||
m.queryInput = i
|
||||
searchQuery := m.queryInput.Value()
|
||||
m.runQuery = &searchQuery
|
||||
m = runQueryAndUpdateTable(m, false)
|
||||
return m, tea.Batch(cmd1, cmd2)
|
||||
}
|
||||
case tea.WindowSizeMsg:
|
||||
m = runQueryAndUpdateTable(m, true)
|
||||
return m, nil
|
||||
case errMsg:
|
||||
m.err = msg
|
||||
return m, nil
|
||||
case offlineMsg:
|
||||
m.isOffline = true
|
||||
return m, nil
|
||||
case bannerMsg:
|
||||
m.banner = msg.banner
|
||||
return m, nil
|
||||
case doneDownloadingMsg:
|
||||
m.isLoading = false
|
||||
return m, nil
|
||||
default:
|
||||
var cmd tea.Cmd
|
||||
if m.isLoading {
|
||||
m.spinner, cmd = m.spinner.Update(msg)
|
||||
return m, cmd
|
||||
} else {
|
||||
m.table, cmd = m.table.Update(msg)
|
||||
return m, cmd
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (m model) View() string {
|
||||
if m.err != nil {
|
||||
return fmt.Sprintf("An unrecoverable error occured: %v\n", m.err)
|
||||
}
|
||||
if m.selected {
|
||||
indexOfCommand := -1
|
||||
for i, columnName := range hctx.GetConf(m.ctx).DisplayedColumns {
|
||||
if columnName == "Command" {
|
||||
indexOfCommand = i
|
||||
break
|
||||
}
|
||||
}
|
||||
if indexOfCommand == -1 {
|
||||
selectedRow = "Error: Table doesn't have a column named `Command`?"
|
||||
return ""
|
||||
}
|
||||
selectedRow = m.table.SelectedRow()[indexOfCommand]
|
||||
return ""
|
||||
}
|
||||
if m.quitting {
|
||||
return ""
|
||||
}
|
||||
loadingMessage := ""
|
||||
if m.isLoading {
|
||||
loadingMessage = fmt.Sprintf("%s Loading hishtory entries from other devices...", m.spinner.View())
|
||||
}
|
||||
warning := ""
|
||||
if m.isOffline {
|
||||
warning += "Warning: failed to contact the hishtory backend (are you offline?), so some results may be stale\n\n"
|
||||
}
|
||||
if m.searchErr != nil {
|
||||
warning += fmt.Sprintf("Warning: failed to search: %v\n\n", m.searchErr)
|
||||
}
|
||||
return fmt.Sprintf("\n%s\n%s%s\nSearch Query: %s\n\n%s\n", loadingMessage, warning, m.banner, m.queryInput.View(), baseStyle.Render(m.table.View()))
|
||||
}
|
||||
|
||||
func getRows(ctx *context.Context, columnNames []string, query string, numEntries int) ([]table.Row, int, error) {
|
||||
db := hctx.GetDb(ctx)
|
||||
config := hctx.GetConf(ctx)
|
||||
data, err := Search(ctx, db, query, numEntries)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
var rows []table.Row
|
||||
lastCommand := ""
|
||||
for i := 0; i < numEntries; i++ {
|
||||
if i < len(data) {
|
||||
entry := data[i]
|
||||
if strings.TrimSpace(entry.Command) == strings.TrimSpace(lastCommand) && config.FilterDuplicateCommands {
|
||||
continue
|
||||
}
|
||||
entry.Command = strings.ReplaceAll(entry.Command, "\n", " ") // TODO: handle multi-line commands better here
|
||||
row, err := buildTableRow(ctx, columnNames, *entry)
|
||||
if err != nil {
|
||||
return nil, 0, fmt.Errorf("failed to build row for entry=%#v: %v", entry, err)
|
||||
}
|
||||
rows = append(rows, row)
|
||||
lastCommand = entry.Command
|
||||
} else {
|
||||
rows = append(rows, table.Row{})
|
||||
}
|
||||
}
|
||||
return rows, len(data), nil
|
||||
}
|
||||
|
||||
func calculateColumnWidths(rows []table.Row) []int {
|
||||
numColumns := len(rows[0])
|
||||
neededColumnWidth := make([]int, numColumns)
|
||||
for _, row := range rows {
|
||||
for i, v := range row {
|
||||
neededColumnWidth[i] = max(neededColumnWidth[i], len(v))
|
||||
}
|
||||
}
|
||||
return neededColumnWidth
|
||||
}
|
||||
|
||||
func getTerminalSize() (int, int, error) {
|
||||
return term.GetSize(2)
|
||||
}
|
||||
|
||||
var bigQueryResults []table.Row
|
||||
|
||||
func makeTableColumns(ctx *context.Context, columnNames []string, rows []table.Row) ([]table.Column, error) {
|
||||
// Handle an initial query with no results
|
||||
if len(rows) == 0 || len(rows[0]) == 0 {
|
||||
allRows, _, err := getRows(ctx, columnNames, "", 25)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return makeTableColumns(ctx, columnNames, allRows)
|
||||
}
|
||||
|
||||
// Calculate the minimum amount of space that we need for each column for the current actual search
|
||||
columnWidths := calculateColumnWidths(rows)
|
||||
totalWidth := 20
|
||||
for i, name := range columnNames {
|
||||
columnWidths[i] = max(columnWidths[i], len(name))
|
||||
totalWidth += columnWidths[i]
|
||||
}
|
||||
|
||||
// Calculate the maximum column width that is useful for each column if we search for the empty string
|
||||
if bigQueryResults == nil {
|
||||
bigRows, _, err := getRows(ctx, columnNames, "", 1000)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
bigQueryResults = bigRows
|
||||
}
|
||||
maximumColumnWidths := calculateColumnWidths(bigQueryResults)
|
||||
|
||||
// Get the actual terminal width. If we're below this, opportunistically add some padding aiming for the maximum column widths
|
||||
terminalWidth, _, err := getTerminalSize()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get terminal size: %v", err)
|
||||
}
|
||||
for totalWidth < (terminalWidth - len(columnNames)) {
|
||||
prevTotalWidth := totalWidth
|
||||
for i := range columnNames {
|
||||
if columnWidths[i] < maximumColumnWidths[i]+5 {
|
||||
columnWidths[i] += 1
|
||||
totalWidth += 1
|
||||
}
|
||||
}
|
||||
if totalWidth == prevTotalWidth {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// And if we are too large from the initial query, let's shrink things to make the table fit. We'll use the heuristic of always shrinking the widest column.
|
||||
for totalWidth > terminalWidth {
|
||||
largestColumnIdx := -1
|
||||
largestColumnSize := -1
|
||||
for i := range columnNames {
|
||||
if columnWidths[i] > largestColumnSize {
|
||||
largestColumnIdx = i
|
||||
largestColumnSize = columnWidths[i]
|
||||
}
|
||||
}
|
||||
columnWidths[largestColumnIdx] -= 2
|
||||
totalWidth -= 2
|
||||
}
|
||||
|
||||
// And finally, create some actual columns!
|
||||
columns := make([]table.Column, 0)
|
||||
for i, name := range columnNames {
|
||||
columns = append(columns, table.Column{Title: name, Width: columnWidths[i]})
|
||||
}
|
||||
return columns, nil
|
||||
}
|
||||
|
||||
func max(a, b int) int {
|
||||
if a > b {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
func min(a, b int) int {
|
||||
if a < b {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
func makeTable(ctx *context.Context, rows []table.Row) (table.Model, error) {
|
||||
config := hctx.GetConf(ctx)
|
||||
columns, err := makeTableColumns(ctx, config.DisplayedColumns, rows)
|
||||
if err != nil {
|
||||
return table.Model{}, err
|
||||
}
|
||||
km := table.KeyMap{
|
||||
LineUp: key.NewBinding(
|
||||
key.WithKeys("up", "alt+OA"),
|
||||
key.WithHelp("↑", "scroll up"),
|
||||
),
|
||||
LineDown: key.NewBinding(
|
||||
key.WithKeys("down", "alt+OB"),
|
||||
key.WithHelp("↓", "scroll down"),
|
||||
),
|
||||
PageUp: key.NewBinding(
|
||||
key.WithKeys("pgup"),
|
||||
key.WithHelp("pgup", "page up"),
|
||||
),
|
||||
PageDown: key.NewBinding(
|
||||
key.WithKeys("pgdown"),
|
||||
key.WithHelp("pgdn", "page down"),
|
||||
),
|
||||
GotoTop: key.NewBinding(
|
||||
key.WithKeys("home"),
|
||||
key.WithHelp("home", "go to start"),
|
||||
),
|
||||
GotoBottom: key.NewBinding(
|
||||
key.WithKeys("end"),
|
||||
key.WithHelp("end", "go to end"),
|
||||
),
|
||||
}
|
||||
_, terminalHeight, err := getTerminalSize()
|
||||
if err != nil {
|
||||
return table.Model{}, err
|
||||
}
|
||||
tableHeight := min(TABLE_HEIGHT, terminalHeight-12)
|
||||
t := table.New(
|
||||
table.WithColumns(columns),
|
||||
table.WithRows(rows),
|
||||
table.WithFocused(true),
|
||||
table.WithHeight(tableHeight),
|
||||
table.WithKeyMap(km),
|
||||
)
|
||||
|
||||
s := table.DefaultStyles()
|
||||
s.Header = s.Header.
|
||||
BorderStyle(lipgloss.NormalBorder()).
|
||||
BorderForeground(lipgloss.Color("240")).
|
||||
BorderBottom(true).
|
||||
Bold(false)
|
||||
s.Selected = s.Selected.
|
||||
Foreground(lipgloss.Color("229")).
|
||||
Background(lipgloss.Color("57")).
|
||||
Bold(false)
|
||||
t.SetStyles(s)
|
||||
t.Focus()
|
||||
return t, nil
|
||||
}
|
||||
|
||||
func TuiQuery(ctx *context.Context, gitCommit, initialQuery string) error {
|
||||
lipgloss.SetColorProfile(termenv.ANSI)
|
||||
rows, numEntries, err := getRows(ctx, hctx.GetConf(ctx).DisplayedColumns, initialQuery, PADDED_NUM_ENTRIES)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
t, err := makeTable(ctx, rows)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
p := tea.NewProgram(initialModel(ctx, t, initialQuery, numEntries), tea.WithOutput(os.Stderr))
|
||||
go func() {
|
||||
err := RetrieveAdditionalEntriesFromRemote(ctx)
|
||||
if err != nil {
|
||||
p.Send(err)
|
||||
}
|
||||
p.Send(doneDownloadingMsg{})
|
||||
}()
|
||||
// Async: Process deletion requests
|
||||
go func() {
|
||||
err := ProcessDeletionRequests(ctx)
|
||||
if err != nil {
|
||||
p.Send(err)
|
||||
}
|
||||
}()
|
||||
// Async: Check for any banner from the server
|
||||
go func() {
|
||||
banner, err := GetBanner(ctx, gitCommit)
|
||||
if err != nil {
|
||||
if IsOfflineError(err) {
|
||||
p.Send(offlineMsg{})
|
||||
} else {
|
||||
p.Send(err)
|
||||
}
|
||||
}
|
||||
p.Send(bannerMsg{banner: string(banner)})
|
||||
}()
|
||||
// Blocking: Start the TUI
|
||||
err = p.Start()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if selectedRow == "" && os.Getenv("HISHTORY_TERM_INTEGRATION") != "" {
|
||||
// Print out the initialQuery instead so that we don't clear the terminal
|
||||
selectedRow = initialQuery
|
||||
}
|
||||
fmt.Printf("%s\n", selectedRow)
|
||||
return nil
|
||||
}
|
40
demo.vhs
Normal file
40
demo.vhs
Normal file
@@ -0,0 +1,40 @@
|
||||
# Demo file used https://github.com/charmbracelet/vhs
|
||||
Output backend/web/landing/www/img/demo.gif
|
||||
Set FontSize 22
|
||||
Set Width 2300
|
||||
Set Height 1050
|
||||
|
||||
# Set up
|
||||
Hide
|
||||
Type "zsh"
|
||||
Enter
|
||||
Type "setopt interactivecomments"
|
||||
Enter
|
||||
Type "clear"
|
||||
Enter
|
||||
Set TypingSpeed 0.1
|
||||
Show
|
||||
|
||||
Type "find . -iname '*.go' | xargs -I {} -- gofmt -w {}"
|
||||
Enter
|
||||
Sleep 4000ms
|
||||
|
||||
Type "ssh server"
|
||||
Enter
|
||||
Sleep 400ms
|
||||
Type "# Then press control + r to search your history"
|
||||
Enter
|
||||
Sleep 3800ms
|
||||
|
||||
Ctrl+R
|
||||
Sleep 6000ms
|
||||
Type "g"
|
||||
Sleep 400ms
|
||||
Type "of
|
||||
Sleep 400ms
|
||||
Type "mt"
|
||||
Sleep 1000ms
|
||||
Type " cwd:~/code/hishtory/"
|
||||
Sleep 7000ms
|
||||
Enter
|
||||
Sleep 6000ms
|
293
go.mod
Normal file
293
go.mod
Normal file
@@ -0,0 +1,293 @@
|
||||
module github.com/ddworken/hishtory
|
||||
|
||||
go 1.18
|
||||
|
||||
require (
|
||||
github.com/araddon/dateparse v0.0.0-20210429162001-6b43995a97de
|
||||
github.com/charmbracelet/bubbles v0.14.0
|
||||
github.com/charmbracelet/bubbletea v0.23.0
|
||||
github.com/charmbracelet/lipgloss v0.6.0
|
||||
github.com/fatih/color v1.13.0
|
||||
github.com/glebarez/sqlite v1.4.7
|
||||
github.com/go-test/deep v1.0.8
|
||||
github.com/google/go-cmp v0.5.9
|
||||
github.com/google/uuid v1.3.0
|
||||
github.com/lib/pq v1.10.4
|
||||
github.com/muesli/termenv v0.13.0
|
||||
github.com/rodaine/table v1.0.1
|
||||
github.com/slsa-framework/slsa-verifier v1.3.2
|
||||
golang.org/x/term v0.2.0
|
||||
gorm.io/driver/postgres v1.3.1
|
||||
gorm.io/driver/sqlite v1.3.6
|
||||
gorm.io/gorm v1.23.8
|
||||
)
|
||||
|
||||
require (
|
||||
bitbucket.org/creachadair/shell v0.0.7 // indirect
|
||||
cloud.google.com/go/compute v1.10.0 // indirect
|
||||
github.com/AliyunContainerService/ack-ram-tool/pkg/credentials/alibabacloudsdkgo/helper v0.2.0 // indirect
|
||||
github.com/Azure/azure-sdk-for-go v67.0.0+incompatible // indirect
|
||||
github.com/Azure/go-autorest v14.2.0+incompatible // indirect
|
||||
github.com/Azure/go-autorest/autorest v0.11.28 // indirect
|
||||
github.com/Azure/go-autorest/autorest/adal v0.9.21 // indirect
|
||||
github.com/Azure/go-autorest/autorest/azure/auth v0.5.11 // indirect
|
||||
github.com/Azure/go-autorest/autorest/azure/cli v0.4.6 // indirect
|
||||
github.com/Azure/go-autorest/autorest/date v0.3.0 // indirect
|
||||
github.com/Azure/go-autorest/logger v0.2.1 // indirect
|
||||
github.com/Azure/go-autorest/tracing v0.6.0 // indirect
|
||||
github.com/PaesslerAG/gval v1.0.0 // indirect
|
||||
github.com/PaesslerAG/jsonpath v0.1.1 // indirect
|
||||
github.com/ThalesIgnite/crypto11 v1.2.5 // indirect
|
||||
github.com/alibabacloud-go/alibabacloud-gateway-spi v0.0.4 // indirect
|
||||
github.com/alibabacloud-go/cr-20160607 v1.0.1 // indirect
|
||||
github.com/alibabacloud-go/cr-20181201 v1.0.10 // indirect
|
||||
github.com/alibabacloud-go/darabonba-openapi v0.1.18 // indirect
|
||||
github.com/alibabacloud-go/debug v0.0.0-20190504072949-9472017b5c68 // indirect
|
||||
github.com/alibabacloud-go/endpoint-util v1.1.1 // indirect
|
||||
github.com/alibabacloud-go/openapi-util v0.0.11 // indirect
|
||||
github.com/alibabacloud-go/tea v1.1.18 // indirect
|
||||
github.com/alibabacloud-go/tea-utils v1.4.4 // indirect
|
||||
github.com/alibabacloud-go/tea-xml v1.1.2 // indirect
|
||||
github.com/aliyun/credentials-go v1.2.3 // indirect
|
||||
github.com/asaskevich/govalidator v0.0.0-20210307081110-f21760c49a8d // indirect
|
||||
github.com/atotto/clipboard v0.1.4 // indirect
|
||||
github.com/aws/aws-sdk-go-v2 v1.16.16 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/config v1.17.8 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/credentials v1.12.21 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.12.17 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/internal/configsources v1.1.23 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.4.17 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/internal/ini v1.3.24 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/ecr v1.15.0 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/ecrpublic v1.12.0 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.9.17 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/sso v1.11.23 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.13.6 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/sts v1.16.19 // indirect
|
||||
github.com/aws/smithy-go v1.13.3 // indirect
|
||||
github.com/awslabs/amazon-ecr-credential-helper/ecr-login v0.0.0-20220228164355-396b2034c795 // indirect
|
||||
github.com/aymanbagabas/go-osc52 v1.2.1 // indirect
|
||||
github.com/benbjohnson/clock v1.1.0 // indirect
|
||||
github.com/beorn7/perks v1.0.1 // indirect
|
||||
github.com/bgentry/speakeasy v0.1.0 // indirect
|
||||
github.com/blang/semver v3.5.1+incompatible // indirect
|
||||
github.com/cenkalti/backoff/v4 v4.1.3 // indirect
|
||||
github.com/census-instrumentation/opencensus-proto v0.3.0 // indirect
|
||||
github.com/cespare/xxhash/v2 v2.1.2 // indirect
|
||||
github.com/chrismellard/docker-credential-acr-env v0.0.0-20220119192733-fe33c00cee21 // indirect
|
||||
github.com/clbanning/mxj/v2 v2.5.6 // indirect
|
||||
github.com/cncf/udpa/go v0.0.0-20210930031921-04548b0d99d4 // indirect
|
||||
github.com/cncf/xds/go v0.0.0-20211130200136-a8f946100490 // indirect
|
||||
github.com/common-nighthawk/go-figure v0.0.0-20210622060536-734e95fb86be // indirect
|
||||
github.com/containerd/console v1.0.3 // indirect
|
||||
github.com/containerd/stargz-snapshotter/estargz v0.12.1 // indirect
|
||||
github.com/coreos/go-oidc/v3 v3.4.0 // indirect
|
||||
github.com/coreos/go-semver v0.3.0 // indirect
|
||||
github.com/coreos/go-systemd/v22 v22.3.2 // indirect
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.2 // indirect
|
||||
github.com/cyberphone/json-canonicalization v0.0.0-20210823021906-dc406ceaf94b // indirect
|
||||
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||
github.com/dimchansky/utfbom v1.1.1 // indirect
|
||||
github.com/docker/cli v20.10.20+incompatible // indirect
|
||||
github.com/docker/distribution v2.8.1+incompatible // indirect
|
||||
github.com/docker/docker v20.10.20+incompatible // indirect
|
||||
github.com/docker/docker-credential-helpers v0.7.0 // indirect
|
||||
github.com/docker/go v1.5.1-1 // indirect
|
||||
github.com/dustin/go-humanize v1.0.0 // indirect
|
||||
github.com/envoyproxy/go-control-plane v0.10.2-0.20220325020618-49ff273808a1 // indirect
|
||||
github.com/envoyproxy/protoc-gen-validate v0.6.2 // indirect
|
||||
github.com/fsnotify/fsnotify v1.5.4 // indirect
|
||||
github.com/fullstorydev/grpcurl v1.8.7 // indirect
|
||||
github.com/ghodss/yaml v1.0.0 // indirect
|
||||
github.com/glebarez/go-sqlite v1.18.2 // indirect
|
||||
github.com/go-chi/chi v4.1.2+incompatible // indirect
|
||||
github.com/go-logr/logr v1.2.3 // indirect
|
||||
github.com/go-logr/stdr v1.2.2 // indirect
|
||||
github.com/go-openapi/analysis v0.21.4 // indirect
|
||||
github.com/go-openapi/errors v0.20.3 // indirect
|
||||
github.com/go-openapi/jsonpointer v0.19.5 // indirect
|
||||
github.com/go-openapi/jsonreference v0.20.0 // indirect
|
||||
github.com/go-openapi/loads v0.21.2 // indirect
|
||||
github.com/go-openapi/runtime v0.24.2 // indirect
|
||||
github.com/go-openapi/spec v0.20.7 // indirect
|
||||
github.com/go-openapi/strfmt v0.21.3 // indirect
|
||||
github.com/go-openapi/swag v0.22.3 // indirect
|
||||
github.com/go-openapi/validate v0.22.0 // indirect
|
||||
github.com/go-playground/locales v0.14.0 // indirect
|
||||
github.com/go-playground/universal-translator v0.18.0 // indirect
|
||||
github.com/go-playground/validator/v10 v10.11.1 // indirect
|
||||
github.com/gogo/protobuf v1.3.2 // indirect
|
||||
github.com/golang-jwt/jwt v3.2.2+incompatible // indirect
|
||||
github.com/golang-jwt/jwt/v4 v4.4.2 // indirect
|
||||
github.com/golang/glog v1.0.0 // indirect
|
||||
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect
|
||||
github.com/golang/mock v1.6.0 // indirect
|
||||
github.com/golang/protobuf v1.5.2 // indirect
|
||||
github.com/golang/snappy v0.0.4 // indirect
|
||||
github.com/google/btree v1.1.2 // indirect
|
||||
github.com/google/certificate-transparency-go v1.1.3 // indirect
|
||||
github.com/google/go-containerregistry v0.12.0 // indirect
|
||||
github.com/google/go-github/v45 v45.2.0 // indirect
|
||||
github.com/google/go-querystring v1.1.0 // indirect
|
||||
github.com/google/gofuzz v1.2.0 // indirect
|
||||
github.com/google/trillian v1.5.0 // indirect
|
||||
github.com/googleapis/gnostic v0.5.5 // indirect
|
||||
github.com/gorilla/websocket v1.4.2 // indirect
|
||||
github.com/grpc-ecosystem/go-grpc-middleware v1.3.0 // indirect
|
||||
github.com/grpc-ecosystem/go-grpc-prometheus v1.2.0 // indirect
|
||||
github.com/grpc-ecosystem/grpc-gateway v1.16.0 // indirect
|
||||
github.com/grpc-ecosystem/grpc-gateway/v2 v2.11.3 // indirect
|
||||
github.com/hashicorp/go-cleanhttp v0.5.2 // indirect
|
||||
github.com/hashicorp/go-retryablehttp v0.7.1 // indirect
|
||||
github.com/hashicorp/golang-lru v0.5.4 // indirect
|
||||
github.com/hashicorp/hcl v1.0.0 // indirect
|
||||
github.com/imdario/mergo v0.3.12 // indirect
|
||||
github.com/in-toto/in-toto-golang v0.3.4-0.20220709202702-fa494aaa0add // indirect
|
||||
github.com/inconshreveable/mousetrap v1.0.1 // indirect
|
||||
github.com/jackc/chunkreader/v2 v2.0.1 // indirect
|
||||
github.com/jackc/pgconn v1.10.1 // indirect
|
||||
github.com/jackc/pgio v1.0.0 // indirect
|
||||
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||
github.com/jackc/pgproto3/v2 v2.2.0 // indirect
|
||||
github.com/jackc/pgservicefile v0.0.0-20200714003250-2b9c44734f2b // indirect
|
||||
github.com/jackc/pgtype v1.9.1 // indirect
|
||||
github.com/jackc/pgx/v4 v4.14.1 // indirect
|
||||
github.com/jedisct1/go-minisign v0.0.0-20211028175153-1c139d1cc84b // indirect
|
||||
github.com/jhump/protoreflect v1.13.0 // indirect
|
||||
github.com/jinzhu/inflection v1.0.0 // indirect
|
||||
github.com/jinzhu/now v1.1.5 // indirect
|
||||
github.com/jmespath/go-jmespath v0.4.0 // indirect
|
||||
github.com/jonboulle/clockwork v0.3.0 // indirect
|
||||
github.com/josharian/intern v1.0.0 // indirect
|
||||
github.com/json-iterator/go v1.1.12 // indirect
|
||||
github.com/klauspost/compress v1.15.11 // indirect
|
||||
github.com/leodido/go-urn v1.2.1 // indirect
|
||||
github.com/letsencrypt/boulder v0.0.0-20220929215747-76583552c2be // indirect
|
||||
github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
|
||||
github.com/magiconair/properties v1.8.6 // indirect
|
||||
github.com/mailru/easyjson v0.7.7 // indirect
|
||||
github.com/mattn/go-colorable v0.1.13 // indirect
|
||||
github.com/mattn/go-isatty v0.0.16 // indirect
|
||||
github.com/mattn/go-localereader v0.0.1 // indirect
|
||||
github.com/mattn/go-runewidth v0.0.14 // indirect
|
||||
github.com/mattn/go-sqlite3 v1.14.15 // indirect
|
||||
github.com/matttproud/golang_protobuf_extensions v1.0.2-0.20181231171920-c182affec369 // indirect
|
||||
github.com/miekg/pkcs11 v1.1.1 // indirect
|
||||
github.com/mitchellh/go-homedir v1.1.0 // indirect
|
||||
github.com/mitchellh/mapstructure v1.5.0 // indirect
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
||||
github.com/modern-go/reflect2 v1.0.2 // indirect
|
||||
github.com/mozillazg/docker-credential-acr-helper v0.3.0 // indirect
|
||||
github.com/muesli/ansi v0.0.0-20221106050444-61f0cd9a192a // indirect
|
||||
github.com/muesli/cancelreader v0.2.2 // indirect
|
||||
github.com/muesli/reflow v0.3.0 // indirect
|
||||
github.com/oklog/ulid v1.3.1 // indirect
|
||||
github.com/olekukonko/tablewriter v0.0.5 // indirect
|
||||
github.com/opencontainers/go-digest v1.0.0 // indirect
|
||||
github.com/opencontainers/image-spec v1.1.0-rc2 // indirect
|
||||
github.com/opentracing/opentracing-go v1.2.0 // indirect
|
||||
github.com/pelletier/go-toml v1.9.5 // indirect
|
||||
github.com/pelletier/go-toml/v2 v2.0.5 // indirect
|
||||
github.com/pkg/errors v0.9.1 // indirect
|
||||
github.com/pmezard/go-difflib v1.0.0 // indirect
|
||||
github.com/prometheus/client_golang v1.13.0 // indirect
|
||||
github.com/prometheus/client_model v0.2.0 // indirect
|
||||
github.com/prometheus/common v0.37.0 // indirect
|
||||
github.com/prometheus/procfs v0.8.0 // indirect
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0 // indirect
|
||||
github.com/rivo/uniseg v0.4.2 // indirect
|
||||
github.com/russross/blackfriday/v2 v2.1.0 // indirect
|
||||
github.com/sassoftware/relic v0.0.0-20210427151427-dfb082b79b74 // indirect
|
||||
github.com/secure-systems-lab/go-securesystemslib v0.4.0 // indirect
|
||||
github.com/segmentio/ksuid v1.0.4 // indirect
|
||||
github.com/shibumi/go-pathspec v1.3.0 // indirect
|
||||
github.com/sigstore/cosign v1.13.1 // indirect
|
||||
github.com/sigstore/fulcio v0.6.0 // indirect
|
||||
github.com/sigstore/rekor v1.0.0 // indirect
|
||||
github.com/sigstore/sigstore v1.4.5 // indirect
|
||||
github.com/sirupsen/logrus v1.9.0 // indirect
|
||||
github.com/skratchdot/open-golang v0.0.0-20200116055534-eef842397966 // indirect
|
||||
github.com/slsa-framework/slsa-github-generator v1.2.0 // indirect
|
||||
github.com/soheilhy/cmux v0.1.5 // indirect
|
||||
github.com/spf13/afero v1.8.2 // indirect
|
||||
github.com/spf13/cast v1.5.0 // indirect
|
||||
github.com/spf13/cobra v1.6.0 // indirect
|
||||
github.com/spf13/jwalterweatherman v1.1.0 // indirect
|
||||
github.com/spf13/pflag v1.0.5 // indirect
|
||||
github.com/spf13/viper v1.13.0 // indirect
|
||||
github.com/stretchr/testify v1.8.0 // indirect
|
||||
github.com/subosito/gotenv v1.4.1 // indirect
|
||||
github.com/syndtr/goleveldb v1.0.1-0.20220721030215-126854af5e6d // indirect
|
||||
github.com/tent/canonical-json-go v0.0.0-20130607151641-96e4ba3a7613 // indirect
|
||||
github.com/thales-e-security/pool v0.0.2 // indirect
|
||||
github.com/theupdateframework/go-tuf v0.5.2-0.20220930112810-3890c1e7ace4 // indirect
|
||||
github.com/titanous/rocacheck v0.0.0-20171023193734-afe73141d399 // indirect
|
||||
github.com/tjfoc/gmsm v1.3.2 // indirect
|
||||
github.com/tmc/grpc-websocket-proxy v0.0.0-20201229170055-e5319fda7802 // indirect
|
||||
github.com/transparency-dev/merkle v0.0.1 // indirect
|
||||
github.com/urfave/cli v1.22.7 // indirect
|
||||
github.com/vbatts/tar-split v0.11.2 // indirect
|
||||
github.com/xanzy/go-gitlab v0.73.1 // indirect
|
||||
github.com/xiang90/probing v0.0.0-20190116061207-43a291ad63a2 // indirect
|
||||
go.etcd.io/bbolt v1.3.6 // indirect
|
||||
go.etcd.io/etcd/api/v3 v3.6.0-alpha.0 // indirect
|
||||
go.etcd.io/etcd/client/pkg/v3 v3.6.0-alpha.0 // indirect
|
||||
go.etcd.io/etcd/client/v2 v2.306.0-alpha.0 // indirect
|
||||
go.etcd.io/etcd/client/v3 v3.6.0-alpha.0 // indirect
|
||||
go.etcd.io/etcd/etcdctl/v3 v3.6.0-alpha.0 // indirect
|
||||
go.etcd.io/etcd/etcdutl/v3 v3.6.0-alpha.0 // indirect
|
||||
go.etcd.io/etcd/pkg/v3 v3.6.0-alpha.0 // indirect
|
||||
go.etcd.io/etcd/raft/v3 v3.6.0-alpha.0 // indirect
|
||||
go.etcd.io/etcd/server/v3 v3.6.0-alpha.0 // indirect
|
||||
go.etcd.io/etcd/tests/v3 v3.6.0-alpha.0 // indirect
|
||||
go.etcd.io/etcd/v3 v3.6.0-alpha.0 // indirect
|
||||
go.mongodb.org/mongo-driver v1.10.0 // indirect
|
||||
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.28.0 // indirect
|
||||
go.opentelemetry.io/otel v1.7.0 // indirect
|
||||
go.opentelemetry.io/otel/exporters/otlp/internal/retry v1.7.0 // indirect
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.7.0 // indirect
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.7.0 // indirect
|
||||
go.opentelemetry.io/otel/sdk v1.7.0 // indirect
|
||||
go.opentelemetry.io/otel/trace v1.7.0 // indirect
|
||||
go.opentelemetry.io/proto/otlp v0.16.0 // indirect
|
||||
go.uber.org/atomic v1.10.0 // indirect
|
||||
go.uber.org/multierr v1.8.0 // indirect
|
||||
go.uber.org/zap v1.23.0 // indirect
|
||||
golang.org/x/crypto v0.1.0 // indirect
|
||||
golang.org/x/exp v0.0.0-20220823124025-807a23277127 // indirect
|
||||
golang.org/x/mod v0.6.0 // indirect
|
||||
golang.org/x/net v0.1.0 // indirect
|
||||
golang.org/x/oauth2 v0.1.0 // indirect
|
||||
golang.org/x/sync v0.1.0 // indirect
|
||||
golang.org/x/sys v0.2.0 // indirect
|
||||
golang.org/x/text v0.4.0 // indirect
|
||||
golang.org/x/time v0.0.0-20220922220347-f3bd1da661af // indirect
|
||||
golang.org/x/tools v0.1.12 // indirect
|
||||
google.golang.org/appengine v1.6.7 // indirect
|
||||
google.golang.org/genproto v0.0.0-20221014213838-99cd37c6964a // indirect
|
||||
google.golang.org/grpc v1.50.1 // indirect
|
||||
google.golang.org/protobuf v1.28.1 // indirect
|
||||
gopkg.in/cheggaaa/pb.v1 v1.0.28 // indirect
|
||||
gopkg.in/inf.v0 v0.9.1 // indirect
|
||||
gopkg.in/ini.v1 v1.67.0 // indirect
|
||||
gopkg.in/natefinch/lumberjack.v2 v2.0.0 // indirect
|
||||
gopkg.in/square/go-jose.v2 v2.6.0 // indirect
|
||||
gopkg.in/yaml.v2 v2.4.0 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
k8s.io/api v0.23.5 // indirect
|
||||
k8s.io/apimachinery v0.23.5 // indirect
|
||||
k8s.io/client-go v0.23.5 // indirect
|
||||
k8s.io/klog/v2 v2.60.1-0.20220317184644-43cc75f9ae89 // indirect
|
||||
k8s.io/kube-openapi v0.0.0-20220124234850-424119656bbf // indirect
|
||||
k8s.io/utils v0.0.0-20220210201930-3a6ce19ff2f9 // indirect
|
||||
modernc.org/libc v1.19.0 // indirect
|
||||
modernc.org/mathutil v1.5.0 // indirect
|
||||
modernc.org/memory v1.4.0 // indirect
|
||||
modernc.org/sqlite v1.19.1 // indirect
|
||||
sigs.k8s.io/json v0.0.0-20211208200746-9f7c6b3444d2 // indirect
|
||||
sigs.k8s.io/release-utils v0.7.3 // indirect
|
||||
sigs.k8s.io/structured-merge-diff/v4 v4.2.1 // indirect
|
||||
sigs.k8s.io/yaml v1.3.0 // indirect
|
||||
)
|
||||
|
||||
replace github.com/rodaine/table => github.com/ddworken/table v1.0.2
|
483
hishtory.go
Normal file
483
hishtory.go
Normal file
@@ -0,0 +1,483 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/ddworken/hishtory/client/data"
|
||||
"github.com/ddworken/hishtory/client/hctx"
|
||||
"github.com/ddworken/hishtory/client/lib"
|
||||
"github.com/ddworken/hishtory/shared"
|
||||
)
|
||||
|
||||
var GitCommit string = "Unknown"
|
||||
|
||||
func main() {
|
||||
if len(os.Args) == 1 {
|
||||
fmt.Println("Must specify a command! Do you mean `hishtory query`?")
|
||||
return
|
||||
}
|
||||
switch os.Args[1] {
|
||||
case "saveHistoryEntry":
|
||||
ctx := hctx.MakeContext()
|
||||
lib.CheckFatalError(maybeUploadSkippedHistoryEntries(ctx))
|
||||
saveHistoryEntry(ctx)
|
||||
case "query":
|
||||
ctx := hctx.MakeContext()
|
||||
lib.CheckFatalError(lib.ProcessDeletionRequests(ctx))
|
||||
query(ctx, strings.Join(os.Args[2:], " "))
|
||||
case "tquery":
|
||||
ctx := hctx.MakeContext()
|
||||
lib.CheckFatalError(lib.TuiQuery(ctx, GitCommit, strings.Join(os.Args[2:], " ")))
|
||||
case "export":
|
||||
ctx := hctx.MakeContext()
|
||||
lib.CheckFatalError(lib.ProcessDeletionRequests(ctx))
|
||||
export(ctx, strings.Join(os.Args[2:], " "))
|
||||
case "redact":
|
||||
fallthrough
|
||||
case "delete":
|
||||
ctx := hctx.MakeContext()
|
||||
lib.CheckFatalError(lib.RetrieveAdditionalEntriesFromRemote(ctx))
|
||||
lib.CheckFatalError(lib.ProcessDeletionRequests(ctx))
|
||||
query := strings.Join(os.Args[2:], " ")
|
||||
force := false
|
||||
if os.Args[2] == "--force" {
|
||||
query = strings.Join(os.Args[3:], " ")
|
||||
force = true
|
||||
}
|
||||
lib.CheckFatalError(lib.Redact(ctx, query, force))
|
||||
case "init":
|
||||
db, err := hctx.OpenLocalSqliteDb()
|
||||
lib.CheckFatalError(err)
|
||||
data, err := lib.Search(nil, db, "", 10)
|
||||
lib.CheckFatalError(err)
|
||||
if len(data) > 0 {
|
||||
fmt.Printf("Your current hishtory profile has saved history entries, are you sure you want to run `init` and reset?\nNote: This won't clear any imported history entries from your existing shell\n[y/N]")
|
||||
reader := bufio.NewReader(os.Stdin)
|
||||
resp, err := reader.ReadString('\n')
|
||||
lib.CheckFatalError(err)
|
||||
if strings.TrimSpace(resp) != "y" {
|
||||
fmt.Printf("Aborting init per user response of %#v\n", strings.TrimSpace(resp))
|
||||
return
|
||||
}
|
||||
}
|
||||
lib.CheckFatalError(lib.Setup(os.Args))
|
||||
if os.Getenv("HISHTORY_SKIP_INIT_IMPORT") == "" {
|
||||
fmt.Println("Importing existing shell history...")
|
||||
ctx := hctx.MakeContext()
|
||||
numImported, err := lib.ImportHistory(ctx, false, false)
|
||||
lib.CheckFatalError(err)
|
||||
if numImported > 0 {
|
||||
fmt.Printf("Imported %v history entries from your existing shell history\n", numImported)
|
||||
}
|
||||
}
|
||||
case "install":
|
||||
lib.CheckFatalError(lib.Install())
|
||||
if os.Getenv("HISHTORY_SKIP_INIT_IMPORT") == "" {
|
||||
db, err := hctx.OpenLocalSqliteDb()
|
||||
lib.CheckFatalError(err)
|
||||
data, err := lib.Search(nil, db, "", 10)
|
||||
lib.CheckFatalError(err)
|
||||
if len(data) < 10 {
|
||||
fmt.Println("Importing existing shell history...")
|
||||
ctx := hctx.MakeContext()
|
||||
numImported, err := lib.ImportHistory(ctx, false, false)
|
||||
lib.CheckFatalError(err)
|
||||
if numImported > 0 {
|
||||
fmt.Printf("Imported %v history entries from your existing shell history\n", numImported)
|
||||
}
|
||||
}
|
||||
}
|
||||
case "uninstall":
|
||||
fmt.Printf("Are you sure you want to uninstall hiSHtory and delete all locally saved history data [y/N]")
|
||||
reader := bufio.NewReader(os.Stdin)
|
||||
resp, err := reader.ReadString('\n')
|
||||
lib.CheckFatalError(err)
|
||||
if strings.TrimSpace(resp) != "y" {
|
||||
fmt.Printf("Aborting uninstall per user response of %#v\n", strings.TrimSpace(resp))
|
||||
return
|
||||
}
|
||||
lib.CheckFatalError(lib.Uninstall(hctx.MakeContext()))
|
||||
case "import":
|
||||
ctx := hctx.MakeContext()
|
||||
numImported, err := lib.ImportHistory(ctx, true, true)
|
||||
lib.CheckFatalError(err)
|
||||
if numImported > 0 {
|
||||
fmt.Printf("Imported %v history entries from your existing shell history\n", numImported)
|
||||
}
|
||||
case "enable":
|
||||
ctx := hctx.MakeContext()
|
||||
lib.CheckFatalError(lib.Enable(ctx))
|
||||
case "disable":
|
||||
ctx := hctx.MakeContext()
|
||||
lib.CheckFatalError(lib.Disable(ctx))
|
||||
case "version":
|
||||
fallthrough
|
||||
case "status":
|
||||
ctx := hctx.MakeContext()
|
||||
config := hctx.GetConf(ctx)
|
||||
fmt.Printf("hiSHtory: v0.%s\nEnabled: %v\n", lib.Version, config.IsEnabled)
|
||||
fmt.Printf("Secret Key: %s\n", config.UserSecret)
|
||||
if len(os.Args) == 3 && os.Args[2] == "-v" {
|
||||
fmt.Printf("User ID: %s\n", data.UserId(config.UserSecret))
|
||||
fmt.Printf("Device ID: %s\n", config.DeviceId)
|
||||
printDumpStatus(config)
|
||||
}
|
||||
fmt.Printf("Commit Hash: %s\n", GitCommit)
|
||||
case "update":
|
||||
err := lib.Update(hctx.MakeContext())
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to update hishtory: %v", err)
|
||||
}
|
||||
case "config-get":
|
||||
ctx := hctx.MakeContext()
|
||||
config := hctx.GetConf(ctx)
|
||||
key := os.Args[2]
|
||||
switch key {
|
||||
case "enable-control-r":
|
||||
fmt.Printf("%v", config.ControlRSearchEnabled)
|
||||
case "filter-duplicate-commands":
|
||||
fmt.Printf("%v", config.FilterDuplicateCommands)
|
||||
case "displayed-columns":
|
||||
for _, col := range config.DisplayedColumns {
|
||||
if strings.Contains(col, " ") {
|
||||
fmt.Printf("%q ", col)
|
||||
} else {
|
||||
fmt.Print(col + " ")
|
||||
}
|
||||
}
|
||||
fmt.Print("\n")
|
||||
case "custom-columns":
|
||||
for _, cc := range config.CustomColumns {
|
||||
fmt.Println(cc.ColumnName + ": " + cc.ColumnCommand)
|
||||
}
|
||||
default:
|
||||
log.Fatalf("Unrecognized config key: %s", key)
|
||||
}
|
||||
case "config-set":
|
||||
ctx := hctx.MakeContext()
|
||||
config := hctx.GetConf(ctx)
|
||||
key := os.Args[2]
|
||||
switch key {
|
||||
case "enable-control-r":
|
||||
val := os.Args[3]
|
||||
if val != "true" && val != "false" {
|
||||
log.Fatalf("Unexpected config value %s, must be one of: true, false", val)
|
||||
}
|
||||
config.ControlRSearchEnabled = (val == "true")
|
||||
lib.CheckFatalError(hctx.SetConfig(config))
|
||||
case "filter-duplicate-commands":
|
||||
val := os.Args[3]
|
||||
if val != "true" && val != "false" {
|
||||
log.Fatalf("Unexpected config value %s, must be one of: true, false", val)
|
||||
}
|
||||
config.FilterDuplicateCommands = (val == "true")
|
||||
lib.CheckFatalError(hctx.SetConfig(config))
|
||||
case "displayed-columns":
|
||||
vals := os.Args[3:]
|
||||
config.DisplayedColumns = vals
|
||||
lib.CheckFatalError(hctx.SetConfig(config))
|
||||
case "timestamp-format":
|
||||
val := os.Args[3]
|
||||
config.TimestampFormat = val
|
||||
lib.CheckFatalError(hctx.SetConfig(config))
|
||||
case "custom-columns":
|
||||
log.Fatalf("Please use config-add and config-delete to interact with custom-columns")
|
||||
default:
|
||||
log.Fatalf("Unrecognized config key: %s", key)
|
||||
}
|
||||
case "config-add":
|
||||
ctx := hctx.MakeContext()
|
||||
config := hctx.GetConf(ctx)
|
||||
key := os.Args[2]
|
||||
switch key {
|
||||
case "custom-column":
|
||||
fallthrough
|
||||
case "custom-columns":
|
||||
columnName := os.Args[3]
|
||||
command := os.Args[4]
|
||||
ctx := hctx.MakeContext()
|
||||
config := hctx.GetConf(ctx)
|
||||
if config.CustomColumns == nil {
|
||||
config.CustomColumns = make([]hctx.CustomColumnDefinition, 0)
|
||||
}
|
||||
config.CustomColumns = append(config.CustomColumns, hctx.CustomColumnDefinition{ColumnName: columnName, ColumnCommand: command})
|
||||
lib.CheckFatalError(hctx.SetConfig(config))
|
||||
case "displayed-columns":
|
||||
vals := os.Args[3:]
|
||||
config.DisplayedColumns = append(config.DisplayedColumns, vals...)
|
||||
lib.CheckFatalError(hctx.SetConfig(config))
|
||||
default:
|
||||
log.Fatalf("Unrecognized config key: %s", key)
|
||||
}
|
||||
case "config-delete":
|
||||
ctx := hctx.MakeContext()
|
||||
config := hctx.GetConf(ctx)
|
||||
key := os.Args[2]
|
||||
switch key {
|
||||
case "custom-columns":
|
||||
columnName := os.Args[2]
|
||||
ctx := hctx.MakeContext()
|
||||
config := hctx.GetConf(ctx)
|
||||
if config.CustomColumns == nil {
|
||||
return
|
||||
}
|
||||
newColumns := make([]hctx.CustomColumnDefinition, 0)
|
||||
deletedColumns := false
|
||||
for _, c := range config.CustomColumns {
|
||||
if c.ColumnName != columnName {
|
||||
newColumns = append(newColumns, c)
|
||||
deletedColumns = true
|
||||
}
|
||||
}
|
||||
if !deletedColumns {
|
||||
log.Fatalf("Did not find a column with name %#v to delete (current columns = %#v)", columnName, config.CustomColumns)
|
||||
}
|
||||
config.CustomColumns = newColumns
|
||||
lib.CheckFatalError(hctx.SetConfig(config))
|
||||
case "displayed-columns":
|
||||
deletedColumns := os.Args[3:]
|
||||
newColumns := make([]string, 0)
|
||||
for _, c := range config.DisplayedColumns {
|
||||
isDeleted := false
|
||||
for _, d := range deletedColumns {
|
||||
if c == d {
|
||||
isDeleted = true
|
||||
}
|
||||
}
|
||||
if !isDeleted {
|
||||
newColumns = append(newColumns, c)
|
||||
}
|
||||
}
|
||||
config.DisplayedColumns = newColumns
|
||||
lib.CheckFatalError(hctx.SetConfig(config))
|
||||
default:
|
||||
log.Fatalf("Unrecognized config key: %s", key)
|
||||
}
|
||||
case "reupload":
|
||||
// Purposefully undocumented since this command is generally not necessary to run
|
||||
lib.CheckFatalError(lib.Reupload(hctx.MakeContext()))
|
||||
case "-h":
|
||||
fallthrough
|
||||
case "help":
|
||||
fmt.Print(`hiSHtory: Better shell history
|
||||
|
||||
Supported commands:
|
||||
'hishtory query': Query for matching commands and display them in a table. Examples:
|
||||
'hishtory query apt-get' # Find shell commands containing 'apt-get'
|
||||
'hishtory query apt-get install' # Find shell commands containing 'apt-get' and 'install'
|
||||
'hishtory query curl cwd:/tmp/' # Find shell commands containing 'curl' run in '/tmp/'
|
||||
'hishtory query curl user:david' # Find shell commands containing 'curl' run by 'david'
|
||||
'hishtory query curl host:x1' # Find shell commands containing 'curl' run on 'x1'
|
||||
'hishtory query exit_code:1' # Find shell commands that exited with status code 1
|
||||
'hishtory query before:2022-02-01' # Find shell commands run before 2022-02-01
|
||||
'hishtory export': Query for matching commands and display them in list without any other
|
||||
metadata. Supports the same query format as 'hishtory query'.
|
||||
'hishtory redact': Query for matching commands and remove them from your shell history (on the
|
||||
current machine and on all remote machines). Supports the same query format as 'hishtory query'.
|
||||
'hishtory update': Securely update hishtory to the latest version.
|
||||
'hishtory disable': Stop recording shell commands
|
||||
'hishtory enable': Start recording shell commands
|
||||
'hishtory status': View status info including the secret key which is needed to sync shell
|
||||
history from another machine.
|
||||
'hishtory init': Set the secret key to enable syncing shell commands from another
|
||||
machine with a matching secret key.
|
||||
'hishtory config-get', 'hishtory config-set', 'hishtory config-add', 'hishtory config-delete': Edit the config. See the README for details on each of the config options.
|
||||
'hishtory uninstall': Permanently uninstall hishtory
|
||||
'hishtory help': View this help page
|
||||
`)
|
||||
default:
|
||||
lib.CheckFatalError(fmt.Errorf("unknown command: %s", os.Args[1]))
|
||||
}
|
||||
}
|
||||
|
||||
func printDumpStatus(config hctx.ClientConfig) {
|
||||
dumpRequests, err := getDumpRequests(config)
|
||||
lib.CheckFatalError(err)
|
||||
fmt.Printf("Dump Requests: ")
|
||||
for _, d := range dumpRequests {
|
||||
fmt.Printf("%#v, ", *d)
|
||||
}
|
||||
fmt.Print("\n")
|
||||
}
|
||||
|
||||
func getDumpRequests(config hctx.ClientConfig) ([]*shared.DumpRequest, error) {
|
||||
if config.IsOffline {
|
||||
return make([]*shared.DumpRequest, 0), nil
|
||||
}
|
||||
resp, err := lib.ApiGet("/api/v1/get-dump-requests?user_id=" + data.UserId(config.UserSecret) + "&device_id=" + config.DeviceId)
|
||||
if lib.IsOfflineError(err) {
|
||||
return []*shared.DumpRequest{}, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var dumpRequests []*shared.DumpRequest
|
||||
err = json.Unmarshal(resp, &dumpRequests)
|
||||
return dumpRequests, err
|
||||
}
|
||||
|
||||
func query(ctx *context.Context, query string) {
|
||||
db := hctx.GetDb(ctx)
|
||||
err := lib.RetrieveAdditionalEntriesFromRemote(ctx)
|
||||
if err != nil {
|
||||
if lib.IsOfflineError(err) {
|
||||
fmt.Println("Warning: hishtory is offline so this may be missing recent results from your other machines!")
|
||||
} else {
|
||||
lib.CheckFatalError(err)
|
||||
}
|
||||
}
|
||||
lib.CheckFatalError(displayBannerIfSet(ctx))
|
||||
numResults := 25
|
||||
data, err := lib.Search(ctx, db, query, numResults*5)
|
||||
lib.CheckFatalError(err)
|
||||
lib.CheckFatalError(lib.DisplayResults(ctx, data, numResults))
|
||||
}
|
||||
|
||||
func displayBannerIfSet(ctx *context.Context) error {
|
||||
respBody, err := lib.GetBanner(ctx, GitCommit)
|
||||
if lib.IsOfflineError(err) {
|
||||
return nil
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(respBody) > 0 {
|
||||
fmt.Println(string(respBody))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func maybeUploadSkippedHistoryEntries(ctx *context.Context) error {
|
||||
config := hctx.GetConf(ctx)
|
||||
if !config.HaveMissedUploads {
|
||||
return nil
|
||||
}
|
||||
if config.IsOffline {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Upload the missing entries
|
||||
db := hctx.GetDb(ctx)
|
||||
query := fmt.Sprintf("after:%s", time.Unix(config.MissedUploadTimestamp, 0).Format("2006-01-02"))
|
||||
entries, err := lib.Search(ctx, db, query, 0)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to retrieve history entries that haven't been uploaded yet: %v", err)
|
||||
}
|
||||
hctx.GetLogger().Infof("Uploading %d history entries that previously failed to upload (query=%#v)\n", len(entries), query)
|
||||
jsonValue, err := lib.EncryptAndMarshal(config, entries)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = lib.ApiPost("/api/v1/submit?source_device_id="+config.DeviceId, "application/json", jsonValue)
|
||||
if err != nil {
|
||||
// Failed to upload the history entry, so we must still be offline. So just return nil and we'll try again later.
|
||||
return nil
|
||||
}
|
||||
|
||||
// Mark down that we persisted it
|
||||
config.HaveMissedUploads = false
|
||||
config.MissedUploadTimestamp = 0
|
||||
err = hctx.SetConfig(config)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to mark a history entry as uploaded: %v", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func saveHistoryEntry(ctx *context.Context) {
|
||||
config := hctx.GetConf(ctx)
|
||||
if !config.IsEnabled {
|
||||
hctx.GetLogger().Infof("Skipping saving a history entry because hishtory is disabled\n")
|
||||
return
|
||||
}
|
||||
entry, err := lib.BuildHistoryEntry(ctx, os.Args)
|
||||
lib.CheckFatalError(err)
|
||||
if entry == nil {
|
||||
hctx.GetLogger().Infof("Skipping saving a history entry because we did not build a history entry (was the command prefixed with a space and/or empty?)\n")
|
||||
return
|
||||
}
|
||||
|
||||
// Persist it locally
|
||||
db := hctx.GetDb(ctx)
|
||||
err = lib.ReliableDbCreate(db, *entry)
|
||||
lib.CheckFatalError(err)
|
||||
|
||||
// Persist it remotely
|
||||
if !config.IsOffline {
|
||||
jsonValue, err := lib.EncryptAndMarshal(config, []*data.HistoryEntry{entry})
|
||||
lib.CheckFatalError(err)
|
||||
_, err = lib.ApiPost("/api/v1/submit?source_device_id="+config.DeviceId, "application/json", jsonValue)
|
||||
if err != nil {
|
||||
if lib.IsOfflineError(err) {
|
||||
hctx.GetLogger().Infof("Failed to remotely persist hishtory entry because we failed to connect to the remote server! This is likely because the device is offline, but also could be because the remote server is having reliability issues. Original error: %v", err)
|
||||
if !config.HaveMissedUploads {
|
||||
config.HaveMissedUploads = true
|
||||
config.MissedUploadTimestamp = time.Now().Unix()
|
||||
lib.CheckFatalError(hctx.SetConfig(config))
|
||||
}
|
||||
} else {
|
||||
lib.CheckFatalError(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check if there is a pending dump request and reply to it if so
|
||||
dumpRequests, err := getDumpRequests(config)
|
||||
if err != nil {
|
||||
if lib.IsOfflineError(err) {
|
||||
// It is fine to just ignore this, the next command will retry the API and eventually we will respond to any pending dump requests
|
||||
dumpRequests = []*shared.DumpRequest{}
|
||||
hctx.GetLogger().Infof("Failed to check for dump requests because we failed to connect to the remote server!")
|
||||
} else {
|
||||
lib.CheckFatalError(err)
|
||||
}
|
||||
}
|
||||
if len(dumpRequests) > 0 {
|
||||
lib.CheckFatalError(lib.RetrieveAdditionalEntriesFromRemote(ctx))
|
||||
entries, err := lib.Search(ctx, db, "", 0)
|
||||
lib.CheckFatalError(err)
|
||||
var encEntries []*shared.EncHistoryEntry
|
||||
for _, entry := range entries {
|
||||
enc, err := data.EncryptHistoryEntry(config.UserSecret, *entry)
|
||||
lib.CheckFatalError(err)
|
||||
encEntries = append(encEntries, &enc)
|
||||
}
|
||||
reqBody, err := json.Marshal(encEntries)
|
||||
lib.CheckFatalError(err)
|
||||
for _, dumpRequest := range dumpRequests {
|
||||
if !config.IsOffline {
|
||||
_, err := lib.ApiPost("/api/v1/submit-dump?user_id="+dumpRequest.UserId+"&requesting_device_id="+dumpRequest.RequestingDeviceId+"&source_device_id="+config.DeviceId, "application/json", reqBody)
|
||||
lib.CheckFatalError(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Handle deletion requests
|
||||
lib.CheckFatalError(lib.ProcessDeletionRequests(ctx))
|
||||
}
|
||||
|
||||
func export(ctx *context.Context, query string) {
|
||||
db := hctx.GetDb(ctx)
|
||||
err := lib.RetrieveAdditionalEntriesFromRemote(ctx)
|
||||
if err != nil {
|
||||
if lib.IsOfflineError(err) {
|
||||
fmt.Println("Warning: hishtory is offline so this may be missing recent results from your other machines!")
|
||||
} else {
|
||||
lib.CheckFatalError(err)
|
||||
}
|
||||
}
|
||||
data, err := lib.Search(ctx, db, query, 0)
|
||||
lib.CheckFatalError(err)
|
||||
for i := len(data) - 1; i >= 0; i-- {
|
||||
fmt.Println(data[i].Command)
|
||||
}
|
||||
}
|
||||
|
||||
// TODO(feature): Add a session_id column that corresponds to the shell session the command was run in
|
58
scripts/actions-sign.py
Normal file
58
scripts/actions-sign.py
Normal file
@@ -0,0 +1,58 @@
|
||||
import os
|
||||
import requests
|
||||
import time
|
||||
import subprocess
|
||||
|
||||
def main():
|
||||
version = os.environ['GITHUB_REF'].split('/')[-1].split("-")[0]
|
||||
print("Downloading binaries (this may pause for a while)")
|
||||
waitUntilPublished(f"https://github.com/ddworken/hishtory/releases/download/{version}/hishtory-darwin-arm64", "hishtory-darwin-arm64")
|
||||
waitUntilPublished(f"https://github.com/ddworken/hishtory/releases/download/{version}/hishtory-darwin-amd64", "hishtory-darwin-amd64")
|
||||
|
||||
print("before sha1sum:")
|
||||
os.system("sha1sum hishtory-* 2>&1")
|
||||
|
||||
print("file:")
|
||||
os.system("file hishtory-* 2>&1")
|
||||
|
||||
notAscii("hishtory-darwin-arm64")
|
||||
notAscii("hishtory-darwin-amd64")
|
||||
|
||||
print("signing...")
|
||||
os.system("""
|
||||
cp hishtory-darwin-arm64 hishtory-darwin-arm64-unsigned
|
||||
cp hishtory-darwin-amd64 hishtory-darwin-amd64-unsigned
|
||||
echo $MACOS_CERTIFICATE | base64 -d > certificate.p12
|
||||
security create-keychain -p $MACOS_CERTIFICATE_PWD build.keychain
|
||||
security default-keychain -s build.keychain
|
||||
security unlock-keychain -p $MACOS_CERTIFICATE_PWD build.keychain
|
||||
security import certificate.p12 -k build.keychain -P $MACOS_CERTIFICATE_PWD -T /usr/bin/codesign
|
||||
security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k $MACOS_CERTIFICATE_PWD build.keychain
|
||||
/usr/bin/codesign --force -s 6D4E1575A0D40C370E294916A8390797106C8A6E hishtory-darwin-arm64 -v
|
||||
/usr/bin/codesign --force -s 6D4E1575A0D40C370E294916A8390797106C8A6E hishtory-darwin-amd64 -v
|
||||
""")
|
||||
|
||||
print("after sha1sum:")
|
||||
os.system("sha1sum hishtory-* 2>&1")
|
||||
|
||||
|
||||
|
||||
def notAscii(fn):
|
||||
out = subprocess.check_output(["file", fn]).decode('utf-8')
|
||||
if "ASCII text" in out:
|
||||
raise Exception(f"fn={fn} is of type {out}")
|
||||
|
||||
def waitUntilPublished(url, output) -> None:
|
||||
startTime = time.time()
|
||||
while True:
|
||||
r = requests.get(url, headers={'authorization': f'bearer {os.environ["GITHUB_TOKEN"]}'})
|
||||
if r.status_code == 200:
|
||||
break
|
||||
if (time.time() - startTime)/60 > 20:
|
||||
raise Exception(f"failed to get url={url} (startTime={startTime}, endTime={time.time()}), status_code=" + str(r.status_code) + " body=" + str(r.content))
|
||||
time.sleep(5)
|
||||
with open(output, 'wb') as f:
|
||||
f.write(r.content)
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
4
scripts/client-ldflags
Executable file
4
scripts/client-ldflags
Executable file
@@ -0,0 +1,4 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
GIT_HASH=$(git rev-parse HEAD)
|
||||
echo "-X main.GitCommit=$GIT_HASH -X github.com/ddworken/hishtory/client/lib.Version=`cat VERSION` -w -extldflags \"-static\""
|
87
shared/data.go
Normal file
87
shared/data.go
Normal file
@@ -0,0 +1,87 @@
|
||||
package shared
|
||||
|
||||
import (
|
||||
"database/sql/driver"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"time"
|
||||
)
|
||||
|
||||
type EncHistoryEntry struct {
|
||||
EncryptedData []byte `json:"enc_data"`
|
||||
Nonce []byte `json:"nonce"`
|
||||
DeviceId string `json:"device_id"`
|
||||
UserId string `json:"user_id"`
|
||||
Date time.Time `json:"time"`
|
||||
EncryptedId string `json:"id"`
|
||||
ReadCount int `json:"read_count"`
|
||||
}
|
||||
|
||||
/*
|
||||
Manually created the indices:
|
||||
CREATE INDEX CONCURRENTLY device_id_idx ON enc_history_entries USING btree(device_id);
|
||||
CREATE INDEX CONCURRENTLY read_count_idx ON enc_history_entries USING btree(read_count);
|
||||
CREATE INDEX CONCURRENTLY redact_idx ON enc_history_entries USING btree(user_id, device_id, date);
|
||||
*/
|
||||
|
||||
type Device struct {
|
||||
UserId string `json:"user_id"`
|
||||
DeviceId string `json:"device_id"`
|
||||
// The IP address that was used to register the device. Recorded so
|
||||
// that I can count how many people are using hishtory and roughly
|
||||
// from where. If you would like this deleted, please email me at
|
||||
// david@daviddworken.com and I can clear it from your device entries.
|
||||
RegistrationIp string `json:"registration_ip"`
|
||||
RegistrationDate time.Time `json:"registration_date"`
|
||||
}
|
||||
|
||||
type DumpRequest struct {
|
||||
UserId string `json:"user_id"`
|
||||
RequestingDeviceId string `json:"requesting_device_id"`
|
||||
RequestTime time.Time `json:"request_time"`
|
||||
}
|
||||
|
||||
type UpdateInfo struct {
|
||||
LinuxAmd64Url string `json:"linux_amd_64_url"`
|
||||
LinuxAmd64AttestationUrl string `json:"linux_amd_64_attestation_url"`
|
||||
DarwinAmd64Url string `json:"darwin_amd_64_url"`
|
||||
DarwinAmd64UnsignedUrl string `json:"darwin_amd_64_unsigned_url"`
|
||||
DarwinAmd64AttestationUrl string `json:"darwin_amd_64_attestation_url"`
|
||||
DarwinArm64Url string `json:"darwin_arm_64_url"`
|
||||
DarwinArm64UnsignedUrl string `json:"darwin_arm_64_unsigned_url"`
|
||||
DarwinArm64AttestationUrl string `json:"darwin_arm_64_attestation_url"`
|
||||
Version string `json:"version"`
|
||||
}
|
||||
|
||||
type DeletionRequest struct {
|
||||
UserId string `json:"user_id"`
|
||||
DestinationDeviceId string `json:"destination_device_id"`
|
||||
SendTime time.Time `json:"send_time"`
|
||||
Messages MessageIdentifiers `json:"messages"`
|
||||
ReadCount int `json:"read_count"`
|
||||
}
|
||||
|
||||
type MessageIdentifiers struct {
|
||||
Ids []MessageIdentifier `json:"message_ids"`
|
||||
}
|
||||
|
||||
type MessageIdentifier struct {
|
||||
DeviceId string `json:"device_id"`
|
||||
Date time.Time `json:"date"`
|
||||
}
|
||||
|
||||
func (m *MessageIdentifiers) Scan(value interface{}) error {
|
||||
bytes, ok := value.([]byte)
|
||||
if !ok {
|
||||
return fmt.Errorf("failed to unmarshal JSONB value: %v", value)
|
||||
}
|
||||
|
||||
result := MessageIdentifiers{}
|
||||
err := json.Unmarshal(bytes, &result)
|
||||
*m = result
|
||||
return err
|
||||
}
|
||||
|
||||
func (m MessageIdentifiers) Value() (driver.Value, error) {
|
||||
return json.Marshal(m)
|
||||
}
|
283
shared/testutils/testutils.go
Normal file
283
shared/testutils/testutils.go
Normal file
@@ -0,0 +1,283 @@
|
||||
package testutils
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/ddworken/hishtory/client/data"
|
||||
)
|
||||
|
||||
const (
|
||||
DB_WAL_PATH = data.DB_PATH + "-wal"
|
||||
DB_SHM_PATH = data.DB_PATH + "-shm"
|
||||
)
|
||||
|
||||
func ResetLocalState(t *testing.T) {
|
||||
homedir, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
t.Fatalf("failed to retrieve homedir: %v", err)
|
||||
}
|
||||
|
||||
_ = BackupAndRestoreWithId(t, "-reset-local-state")
|
||||
_ = os.RemoveAll(path.Join(homedir, data.HISHTORY_PATH))
|
||||
}
|
||||
|
||||
func BackupAndRestore(t *testing.T) func() {
|
||||
return BackupAndRestoreWithId(t, "")
|
||||
}
|
||||
|
||||
func getBackPath(file, id string) string {
|
||||
if strings.Contains(file, "/"+data.HISHTORY_PATH+"/") {
|
||||
return strings.Replace(file, data.HISHTORY_PATH, data.HISHTORY_PATH+".test", 1) + id
|
||||
}
|
||||
return file + ".bak" + id
|
||||
}
|
||||
|
||||
func BackupAndRestoreWithId(t *testing.T, id string) func() {
|
||||
ResetFakeHistoryTimestamp()
|
||||
homedir, err := os.UserHomeDir()
|
||||
Check(t, err)
|
||||
initialWd, err := os.Getwd()
|
||||
Check(t, err)
|
||||
Check(t, os.MkdirAll(path.Join(homedir, data.HISHTORY_PATH+".test"), os.ModePerm))
|
||||
|
||||
renameFiles := []string{
|
||||
path.Join(homedir, data.HISHTORY_PATH, data.DB_PATH),
|
||||
path.Join(homedir, data.HISHTORY_PATH, DB_WAL_PATH),
|
||||
path.Join(homedir, data.HISHTORY_PATH, DB_SHM_PATH),
|
||||
path.Join(homedir, data.HISHTORY_PATH, data.CONFIG_PATH),
|
||||
path.Join(homedir, data.HISHTORY_PATH, "hishtory"),
|
||||
path.Join(homedir, data.HISHTORY_PATH, "config.sh"),
|
||||
path.Join(homedir, data.HISHTORY_PATH, "config.zsh"),
|
||||
path.Join(homedir, data.HISHTORY_PATH, "config.fish"),
|
||||
path.Join(homedir, ".bash_history"),
|
||||
path.Join(homedir, ".zsh_history"),
|
||||
path.Join(homedir, ".local/share/fish/fish_history"),
|
||||
}
|
||||
for _, file := range renameFiles {
|
||||
touchFile(file)
|
||||
_ = os.Rename(file, getBackPath(file, id))
|
||||
}
|
||||
copyFiles := []string{
|
||||
path.Join(homedir, ".zshrc"),
|
||||
path.Join(homedir, ".bashrc"),
|
||||
path.Join(homedir, ".bash_profile"),
|
||||
}
|
||||
for _, file := range copyFiles {
|
||||
touchFile(file)
|
||||
_ = copy(file, getBackPath(file, id))
|
||||
}
|
||||
configureZshrc(homedir)
|
||||
touchFile(path.Join(homedir, ".bash_history"))
|
||||
touchFile(path.Join(homedir, ".zsh_history"))
|
||||
touchFile(path.Join(homedir, ".local/share/fish/fish_history"))
|
||||
return func() {
|
||||
Check(t, os.MkdirAll(path.Join(homedir, data.HISHTORY_PATH), os.ModePerm))
|
||||
for _, file := range renameFiles {
|
||||
checkError(os.Rename(getBackPath(file, id), file))
|
||||
}
|
||||
for _, file := range copyFiles {
|
||||
checkError(copy(getBackPath(file, id), file))
|
||||
}
|
||||
if runtime.GOOS != "windows" {
|
||||
cmd := exec.Command("killall", "hishtory")
|
||||
stdout, err := cmd.Output()
|
||||
if err != nil && err.Error() != "exit status 1" {
|
||||
t.Fatalf("failed to execute killall hishtory, stdout=%#v: %v", string(stdout), err)
|
||||
}
|
||||
}
|
||||
checkError(os.Chdir(initialWd))
|
||||
}
|
||||
}
|
||||
|
||||
func touchFile(p string) {
|
||||
_, err := os.Stat(p)
|
||||
if os.IsNotExist(err) {
|
||||
checkError(os.MkdirAll(filepath.Dir(p), os.ModePerm))
|
||||
file, err := os.Create(p)
|
||||
checkError(err)
|
||||
defer file.Close()
|
||||
} else {
|
||||
currentTime := time.Now().Local()
|
||||
err := os.Chtimes(p, currentTime, currentTime)
|
||||
checkError(err)
|
||||
}
|
||||
}
|
||||
|
||||
func configureZshrc(homedir string) {
|
||||
f, err := os.OpenFile(path.Join(homedir, ".zshrc"), os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
|
||||
checkError(err)
|
||||
defer f.Close()
|
||||
_, err = f.WriteString(`export HISTFILE=~/.zsh_history
|
||||
export HISTSIZE=10000
|
||||
export SAVEHIST=1000
|
||||
setopt SHARE_HISTORY
|
||||
`)
|
||||
checkError(err)
|
||||
}
|
||||
|
||||
func copy(src, dst string) error {
|
||||
in, err := os.Open(src)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer in.Close()
|
||||
|
||||
out, err := os.Create(dst)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer out.Close()
|
||||
|
||||
_, err = io.Copy(out, in)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return out.Close()
|
||||
}
|
||||
|
||||
func BackupAndRestoreEnv(k string) func() {
|
||||
origValue := os.Getenv(k)
|
||||
return func() {
|
||||
os.Setenv(k, origValue)
|
||||
}
|
||||
}
|
||||
|
||||
func checkError(err error) {
|
||||
if err != nil {
|
||||
_, filename, line, _ := runtime.Caller(1)
|
||||
_, cf, cl, _ := runtime.Caller(2)
|
||||
log.Fatalf("testutils fatal error at %s:%d (caller: %s:%d): %v", filename, line, cf, cl, err)
|
||||
}
|
||||
}
|
||||
|
||||
func buildServer() string {
|
||||
for i := 0; i < 100; i++ {
|
||||
wd, err := os.Getwd()
|
||||
if err != nil {
|
||||
panic(fmt.Sprintf("failed to getwd: %v", err))
|
||||
}
|
||||
if strings.HasSuffix(wd, "hishtory") {
|
||||
break
|
||||
}
|
||||
err = os.Chdir("../")
|
||||
if err != nil {
|
||||
panic(fmt.Sprintf("failed to chdir: %v", err))
|
||||
}
|
||||
if wd == "/" {
|
||||
panic("failed to cd into hishtory dir!")
|
||||
}
|
||||
}
|
||||
version, err := os.ReadFile("VERSION")
|
||||
if err != nil {
|
||||
if runtime.GOOS == "windows" {
|
||||
// TODO: Figure out why it can't read the VERSION file
|
||||
version = []byte("174")
|
||||
} else {
|
||||
panic(fmt.Sprintf("failed to read VERSION file: %v", err))
|
||||
}
|
||||
}
|
||||
f, err := os.CreateTemp("", "server")
|
||||
checkError(err)
|
||||
fn := f.Name()
|
||||
cmd := exec.Command("go", "build", "-o", fn, "-ldflags", fmt.Sprintf("-X main.ReleaseVersion=v0.%s", version), "backend/server/server.go")
|
||||
var stdout bytes.Buffer
|
||||
cmd.Stdout = &stdout
|
||||
var stderr bytes.Buffer
|
||||
cmd.Stderr = &stderr
|
||||
err = cmd.Start()
|
||||
if err != nil {
|
||||
panic(fmt.Sprintf("failed to start to build server: %v, stderr=%#v, stdout=%#v", err, stderr.String(), stdout.String()))
|
||||
}
|
||||
err = cmd.Wait()
|
||||
if err != nil {
|
||||
wd, _ := os.Getwd()
|
||||
panic(fmt.Sprintf("failed to build server: %v, wd=%#v, stderr=%#v, stdout=%#v", err, wd, stderr.String(), stdout.String()))
|
||||
}
|
||||
return fn
|
||||
}
|
||||
|
||||
func RunTestServer() func() {
|
||||
os.Setenv("HISHTORY_SERVER", "http://localhost:8080")
|
||||
fn := buildServer()
|
||||
cmd := exec.Command(fn)
|
||||
var stdout bytes.Buffer
|
||||
cmd.Stdout = &stdout
|
||||
var stderr bytes.Buffer
|
||||
cmd.Stderr = &stderr
|
||||
err := cmd.Start()
|
||||
if err != nil {
|
||||
panic(fmt.Sprintf("failed to start server: %v", err))
|
||||
}
|
||||
time.Sleep(time.Second * 5)
|
||||
go func() {
|
||||
_ = cmd.Wait()
|
||||
}()
|
||||
return func() {
|
||||
err := cmd.Process.Kill()
|
||||
if err != nil && err.Error() != "os: process already finished" {
|
||||
panic(fmt.Sprintf("failed to kill server process: %v", err))
|
||||
}
|
||||
if strings.Contains(stderr.String()+stdout.String(), "failed to") && IsOnline() {
|
||||
panic(fmt.Sprintf("server failed to do something: stderr=%#v, stdout=%#v", stderr.String(), stdout.String()))
|
||||
}
|
||||
if strings.Contains(stderr.String()+stdout.String(), "ERROR:") {
|
||||
panic(fmt.Sprintf("server experienced an error: stderr=%#v, stdout=%#v", stderr.String(), stdout.String()))
|
||||
}
|
||||
// fmt.Printf("stderr=%#v, stdout=%#v\n", stderr.String(), stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
func Check(t *testing.T, err error) {
|
||||
if err != nil {
|
||||
_, filename, line, _ := runtime.Caller(1)
|
||||
t.Fatalf("Unexpected error at %s:%d: %v", filename, line, err)
|
||||
}
|
||||
}
|
||||
|
||||
func CheckWithInfo(t *testing.T, err error, additionalInfo string) {
|
||||
if err != nil {
|
||||
_, filename, line, _ := runtime.Caller(1)
|
||||
t.Fatalf("Unexpected error: %v at %s:%d! Additional info: %v", err, filename, line, additionalInfo)
|
||||
}
|
||||
}
|
||||
|
||||
func IsOnline() bool {
|
||||
_, err := http.Get("https://hishtory.dev")
|
||||
return err == nil
|
||||
}
|
||||
|
||||
var fakeHistoryTimestamp int64 = 1666068191
|
||||
|
||||
func ResetFakeHistoryTimestamp() {
|
||||
fakeHistoryTimestamp = 1666068191
|
||||
}
|
||||
|
||||
func MakeFakeHistoryEntry(command string) data.HistoryEntry {
|
||||
fakeHistoryTimestamp += 5
|
||||
return data.HistoryEntry{
|
||||
LocalUsername: "david",
|
||||
Hostname: "localhost",
|
||||
Command: command,
|
||||
CurrentWorkingDirectory: "/tmp/",
|
||||
HomeDirectory: "/home/david/",
|
||||
ExitCode: 2,
|
||||
StartTime: time.Unix(fakeHistoryTimestamp, 0),
|
||||
EndTime: time.Unix(fakeHistoryTimestamp+3, 0),
|
||||
}
|
||||
}
|
||||
|
||||
func IsGithubAction() bool {
|
||||
return os.Getenv("GITHUB_ACTION") != ""
|
||||
}
|
Reference in New Issue
Block a user