89 Commits

Author SHA1 Message Date
Spencer Brower
f0b12582ba chore(dev): release 0.4.0 2026-06-18 10:35:49 -04:00
5059572951 fix: Fixed memory leaks in the db. 2026-06-18 10:35:21 -04:00
d2b84ac4c6 refactor(crypto): used a proper init procedure. 2026-06-18 08:18:09 -04:00
96bc218c46 style: Ordered procedures by usage, with main at the top. 2026-06-18 08:08:46 -04:00
3b32e365c9 chore: Updated TODOS.md 2026-06-18 07:45:38 -04:00
12574e123b feat: Removed runtime git dependency.
This also allowed us to drop the Features code.
2026-06-18 07:29:44 -04:00
bc464a3410 chore: Removed completed todo. 2026-06-17 18:03:54 -04:00
2ef733fe58 perf: Replaced fd with custom internals. 2026-06-17 17:56:31 -04:00
159ff91938 build: Incremented flake version number. 2026-06-16 11:56:10 -04:00
Spencer Brower
84550d4708 chore(main): release 0.3.0 2026-06-16 11:43:36 -04:00
fe2b256bd6 feat: All encryption/decryption now happens in-memory.
Release-as: v0.3.0
2026-06-16 11:38:20 -04:00
41decd9cdb ci: Updated release-please. 2026-06-16 11:36:05 -04:00
397f45d4d0 chore: Completed todos. 2026-06-16 11:36:05 -04:00
73a41830d1 docs: Removed completed TODOs. 2026-06-16 11:36:05 -04:00
e17d04c93d test: Table tests now view full output. 2026-06-16 11:36:05 -04:00
4600c81401 test: commands now accept stdout/stderr fields. 2026-06-16 11:36:05 -04:00
ec96dff055 chore: Cleaned up code. 2026-06-16 11:36:05 -04:00
4a26ee8145 feat: Config can be loaded from any path with --config-file (-c) flag. 2026-06-16 11:36:05 -04:00
e23ea960d7 test: Added missing tests. 2026-06-16 11:36:04 -04:00
3db86f0d2e refactor: Fixed cli command. 2026-06-16 11:36:04 -04:00
567cc8b1e2 tests: Added plan for improving testing. 2026-06-16 11:36:04 -04:00
fe3253f274 refactor: Fixed duplicate terminal checks. 2026-06-16 11:36:04 -04:00
f6ffeeee65 docs: Created table improvement plan. 2026-06-16 11:36:04 -04:00
23d5ff5e01 style: Removed unused code. 2026-06-16 11:36:04 -04:00
4599b25b1b refactor: Removed duplicate insert calls. 2026-06-16 11:36:04 -04:00
e32f0ea6d2 refactor: Fixed logic bug in db. 2026-06-16 11:36:03 -04:00
650c91d51b ci: Updated release-please. 2026-06-16 11:36:03 -04:00
ad3ce748bb docs: updated README.md 2026-06-16 11:36:03 -04:00
930c3d4c5d ci: Updated github action. 2026-06-16 11:36:03 -04:00
b1b0449b7b refactor: Removed go code. 2026-06-16 11:36:03 -04:00
0a74b0dbcc build: Converted Makefile and flake package. 2026-06-16 11:36:03 -04:00
d56f11250c refactor: removed is_tty. 2026-06-16 11:36:03 -04:00
23b8c2dc67 feat: Switched from age to libsodium.
This means, fewer dependencies, a smaller binary, and more secure data.

BREAKING CHANGE: The encryption format of databases has changed. Age
encryption is no longer supported, and no automatic migration path was
implemented.
2026-06-16 11:34:36 -04:00
2f4a7887ea docs: Updated TODOs. 2026-06-16 10:48:11 -04:00
5eee6cd6ea refactor(odin): Migrated nushell-completion command to go. 2026-06-16 10:48:10 -04:00
67f735a654 test: Added tests. 2026-06-16 10:48:10 -04:00
7d16dae4f4 refactor: Fixed the rest of the (tested) leaks. 2026-06-16 10:48:10 -04:00
365e9149b1 perf: Improved writer performance. 2026-06-16 10:48:10 -04:00
1068458f32 refactor: Fixed a number of memory leaks. 2026-06-16 10:48:10 -04:00
22a517340a refactor(odin): Added proper help text to all commands. 2026-06-16 10:48:10 -04:00
fcee4ca7b1 refactor: Got rid of go fallback code. 2026-06-16 10:48:10 -04:00
dff5235d65 refactor: Fixed memory leaks in find_binary. 2026-06-16 10:48:09 -04:00
5865315161 refactor(odin): Ported init command. 2026-06-16 10:48:09 -04:00
191ba305ef refactor(odin): Ported scan command. 2026-06-16 10:48:09 -04:00
d890c88b6d refactor(odin): port check command. 2026-06-16 10:48:09 -04:00
f8add2ad22 refactor(odin): Fixed AI mistakes. 2026-06-16 10:47:57 -04:00
2de7e20f5c refactor(odin): ported edit-config command. 2026-06-16 10:22:25 -04:00
8dd6b17cb9 refactor(odin): Ported restore command. 2026-06-16 10:22:08 -04:00
83b940337c refactor(odin): Ported remove command. 2026-06-16 10:21:46 -04:00
83a8caf691 refactor(odin): Added long text and --help flags. 2026-06-16 10:21:24 -04:00
1964698e35 refactor(odin): ported backup command. 2026-06-16 10:18:56 -04:00
de2186a2e5 refactor(odin): ported list command. 2026-06-16 10:18:21 -04:00
cb51a398ad refactor(odin): ported deps command, added utilities (features, tty, table). 2026-06-16 10:18:05 -04:00
e989b88303 refactor: scaffolded odin project with CLI parser, version command, Go fallback 2026-06-16 10:17:12 -04:00
Spencer Brower
c6d0308842 chore(main): release 0.2.1 2026-01-12 14:42:05 -05:00
cf363abc4d fix: Added add as an alias for backup. 2026-01-12 14:40:42 -05:00
d3dbf2a05a build(nix): Updated flake. 2026-01-12 14:40:38 -05:00
5a9038df87 docs: Added WINDOWS.md for when I add windows support. 2025-11-11 11:02:52 -05:00
Spencer Brower
06e0d8067c chore(main): release 0.2.0 2025-11-10 10:20:56 -05:00
4db0a4d33d feat(sync): envr can now detect if directories have moved. 2025-11-10 10:13:18 -05:00
638751fb48 refactor: Restore and Sync now share code through the sync function. 2025-11-10 10:13:18 -05:00
39dc586d3c refactor: Swapped position of Sync and Restore for better readability. 2025-11-10 10:13:18 -05:00
5eaf691dcd refactor(db): Removed the need to pass CloseMode to Db.Close. 2025-11-07 14:43:30 -05:00
1a3172dc6f docs: Updated comments on SshKeyPair. 2025-11-07 12:19:29 -05:00
66b113049b refactor: Removed TODOs. 2025-11-07 11:51:11 -05:00
169653d756 feat(init): Added a --force flag for overwriting an existing config. 2025-11-07 11:48:36 -05:00
8074f7ae6d feat(sync): Now checks files for mismatched hashes before replacing. 2025-11-07 11:38:58 -05:00
9a729e6e2a docs: Removed old TODO. 2025-11-07 11:16:27 -05:00
0fef74a9bb refactor!: Dir is no longer stored in the database.
BREAKING CHANGE: Dir is now derived from Path rather than stored in the
DB. Your DB will need to be updated.
2025-11-07 11:12:29 -05:00
38a6776b31 chore: remotes now get unmarshalled from the database. 2025-11-07 10:54:54 -05:00
15be62b5a2 feat(config): The default config now filters out more junk.
This includes `.envrc` files, `.local/`, `node_modules`, and `vendor`.
2025-11-07 10:44:55 -05:00
f43705cd53 feat(scan)!: Added support for multiple exports.
BREAKING CHANGE: The config value `scan.Exclude` is now a list rather than a string.
2025-11-07 10:41:46 -05:00
cbd74f387e feat: Added new check command. 2025-11-06 17:35:11 -05:00
c9c34ce771 refactor(check)!: Renamed the check command to deps. 2025-11-06 17:10:53 -05:00
17ce49cd2d fix(check): fd now correctly gets marked as found. 2025-11-06 17:06:26 -05:00
af0a9f9c4c docs: Added todos. 2025-11-05 18:31:39 -05:00
4273fa5895 feat!: Multiple scan includes are now supported.
BREAKING CHANGE: The config value `scan.Include` is now a list rather than
a string.

Release-As: 0.2.0
2025-11-05 18:29:19 -05:00
bb3c0cdeee chore: Updated nix version. 2025-11-05 18:09:58 -05:00
Spencer Brower
df5fdeee67 chore(main): release 0.1.1 2025-11-05 18:08:40 -05:00
42796ec77b feat(sync): Results are now displayed in a table.
Release-As: 0.1.1
2025-11-05 17:49:22 -05:00
8a349dd760 build: Added Makefile for cross-platform builds. 2025-11-05 17:28:59 -05:00
7a49858a58 docs: Updated TODOs. 2025-11-05 16:35:07 -05:00
9ab72a25fa fix(sync): Fixed an issue where deleted folders would be restored. 2025-11-05 15:43:32 -05:00
35519550ed build: Added release-please. 2025-11-05 15:34:06 -05:00
Spencer Brower
7a60c673be build: Updated go version for github action. 2025-11-05 15:34:03 -05:00
Spencer Brower
f8dc85a8b7 build: Created github actions build. 2025-11-04 14:06:13 -05:00
5310803c8b docs: Updated README.md 2025-11-03 18:29:23 -05:00
611ca6f820 docs: Added docs. 2025-11-03 18:22:23 -05:00
d6ef585a45 refactor: Rewrote in golang. 2025-11-03 18:22:13 -05:00
79 changed files with 7651 additions and 414 deletions

2
.envrc
View File

@@ -1,3 +1 @@
use flake use flake
export PATH="$PATH:./vendor/bin"

32
.github/workflows/odin.yml vendored Normal file
View File

@@ -0,0 +1,32 @@
name: Odin
on:
push:
branches: ["main"]
pull_request:
branches: ["main"]
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- name: Install dependencies
run: |
sudo apt-get update
sudo apt-get install -y libsodium-dev sqlite3 libsqlite3-dev libsodium-dev
- name: Install Odin
run: |
git clone https://github.com/odin-lang/Odin.git /opt/odin
cd /opt/odin
./build_odin.sh release
echo "/opt/odin" >> "$GITHUB_PATH"
- name: Build
run: |
odin build . -o:speed -out:envr
- name: Test
run: odin test .

28
.github/workflows/release-please.yml vendored Normal file
View File

@@ -0,0 +1,28 @@
on:
push:
branches:
- main
- dev
- odin
permissions:
contents: write
issues: write
pull-requests: write
name: release-please
jobs:
release-please:
runs-on: ubuntu-latest
steps:
- uses: googleapis/release-please-action@v5
with:
# this assumes that you have created a personal access token
# (PAT) and configured it as a GitHub action secret named
# `MY_RELEASE_PLEASE_TOKEN` (this secret name is not important).
token: ${{ secrets.MY_RELEASE_PLEASE_TOKEN }}
# this is a built-in strategy in release-please, see "Action Inputs"
# for more options
release-type: simple
target-branch: ${{ github.ref_name }}

19
.gitignore vendored
View File

@@ -1,3 +1,18 @@
# dev env
.direnv .direnv
node_modules
vendor list.json
# docs
man
# build artifacts
*.spall
builds
envr
envr-go
findr/findr
findr/findr-prof
findr/bench-*.md
result
version.odin

1
.tokeignore Normal file
View File

@@ -0,0 +1 @@
**/*_test.{odin,go}

90
CHANGELOG.md Normal file
View File

@@ -0,0 +1,90 @@
# Changelog
## [0.4.0](https://github.com/sbrow/envr/compare/v0.3.0...v0.4.0) (2026-06-18)
### Features
* Removed runtime git dependency. ([12574e1](https://github.com/sbrow/envr/commit/12574e123bdedba3aca813143e906ec5e0b95719))
### Bug Fixes
* Fixed memory leaks in the db. ([5059572](https://github.com/sbrow/envr/commit/5059572951b3ec20b3d2027032a9c3be5cb14dba))
### Performance Improvements
* Replaced `fd` with custom internals. ([2ef733f](https://github.com/sbrow/envr/commit/2ef733fe58594b0a0b6e3ef85142b74af445ccb8))
## [0.3.0](https://github.com/sbrow/envr/compare/v0.2.1...v0.3.0) (2026-06-16)
Version 0.3.0 represents a significant departure (and improvement) for envr.
The entire codebase was rewritten in [Odin](https://odin-lang.org/) (from Go).
This reduced the binary size from over 17MB to under 600k, improved performance,
and significantly reduced the number of project dependencies from 69 to just 2.
### ⚠ BREAKING CHANGES
* The encryption format of databases has changed. Age encryption is no longer supported, and no automatic migration path was implemented.
### Features
* All encryption/decryption now happens in-memory. ([fe2b256](https://github.com/sbrow/envr/commit/fe2b256bd61eaf551d53faf3893b473a64a94667))
* Config can be loaded from any path with `--config-file (-c)` flag. ([4a26ee8](https://github.com/sbrow/envr/commit/4a26ee814591e6aab0eb99d2359d51b31011edfe))
* Switched from age to libsodium. ([23b8c2d](https://github.com/sbrow/envr/commit/23b8c2dc671a23cf76cf6746b33806ded9381486))
### Performance Improvements
* Improved writer performance. ([365e914](https://github.com/sbrow/envr/commit/365e9149b1a738ac9119bb5f74dc7e047ecfed5b))
## [0.2.1](https://github.com/sbrow/envr/compare/v0.2.0...v0.2.1) (2026-01-12)
### Bug Fixes
* Added `add` as an alias for backup. ([cf363ab](https://github.com/sbrow/envr/commit/cf363abc4d8cec208d23c6acedbb7e0dd6900332))
## [0.2.0](https://github.com/sbrow/envr/compare/v0.1.1...v0.2.0) (2025-11-10)
### ⚠ BREAKING CHANGES
* Dir is now derived from Path rather than stored in the DB. Your DB will need to be updated.
* **scan:** The config value `scan.Exclude` is now a list rather than a string.
* **check:** Renamed the `check` command to `deps`.
* The config value `scan.Include` is now a list rather than a string.
### Features
* Added new `check` command. ([cbd74f3](https://github.com/sbrow/envr/commit/cbd74f387e2e330b2557d07dd82ba05cc91300ac))
* **config:** The default config now filters out more junk. ([15be62b](https://github.com/sbrow/envr/commit/15be62b5a2a5a735b90b074497d645c5a2cfced8))
* **init:** Added a `--force` flag for overwriting an existing config. ([169653d](https://github.com/sbrow/envr/commit/169653d7566f63730fb9da80a18330a566223be9))
* Multiple scan includes are now supported. ([4273fa5](https://github.com/sbrow/envr/commit/4273fa58956d8736271a0af66202dca481126fe4))
* **scan:** Added support for multiple exports. ([f43705c](https://github.com/sbrow/envr/commit/f43705cd53c6d87aef1f69df4e474441f25c1dc7))
* **sync:** envr can now detect if directories have moved. ([4db0a4d](https://github.com/sbrow/envr/commit/4db0a4d33d2b6a79d13b36a8e8631f895e8fef8d))
* **sync:** Now checks files for mismatched hashes before replacing. ([8074f7a](https://github.com/sbrow/envr/commit/8074f7ae6dfa54e931a198257f3f8e6d0cfe353a))
### Bug Fixes
* **check:** `fd` now correctly gets marked as found. ([17ce49c](https://github.com/sbrow/envr/commit/17ce49cd2d33942282c6f54ce819ac25978f6b7c))
### Code Refactoring
* **check:** Renamed the `check` command to `deps`. ([c9c34ce](https://github.com/sbrow/envr/commit/c9c34ce771653da214635f1df1fef1f23265c552))
* Dir is no longer stored in the database. ([0fef74a](https://github.com/sbrow/envr/commit/0fef74a9bba0fbf3c34b66c2095955e6eee7047b))
## [0.1.1](https://github.com/sbrow/envr/compare/v0.1.0...v0.1.1) (2025-11-05)
### Features
* **sync:** Results are now displayed in a table. ([42796ec](https://github.com/sbrow/envr/commit/42796ec77b1817e1b9f09068d76a7b6e30da246b))
### Bug Fixes
* **sync:** Fixed an issue where deleted folders would be restored. ([9ab72a2](https://github.com/sbrow/envr/commit/9ab72a25faf1af0eedb2f4574166c6ee47450ebb))

92
Makefile Normal file
View File

@@ -0,0 +1,92 @@
# Makefile for envr - Environment file manager
# Builds release artifacts for GitHub releases
APP_NAME := envr
VERSION := $(shell grep 'version = ' flake.nix | head -1 | sed 's/.*version = "\(.*\)";/\1/')
BUILD_DIR := builds
# Binary names
LINUX_AMD64_BIN := $(BUILD_DIR)/$(APP_NAME)-$(VERSION)-linux-amd64
LINUX_ARM64_BIN := $(BUILD_DIR)/$(APP_NAME)-$(VERSION)-linux-arm64
DARWIN_ARM64_BIN := $(BUILD_DIR)/$(APP_NAME)-$(VERSION)-darwin-arm64
.PHONY: all clean cleanall build-linux build-darwin compress release help
# Default target
all: release clean
# Create build directory
$(BUILD_DIR):
@mkdir -p $(BUILD_DIR)
# Build Linux AMD64
$(LINUX_AMD64_BIN): $(BUILD_DIR)
@echo "Building for Linux AMD64..."
odin build . -target:linux_amd64 -o:speed -out:$(LINUX_AMD64_BIN)
@echo "Built $(LINUX_AMD64_BIN)"
# Build Linux ARM64
$(LINUX_ARM64_BIN): $(BUILD_DIR)
@echo "Building for Linux ARM64..."
odin build . -target:linux_arm64 -o:speed -out:$(LINUX_ARM64_BIN)
@echo "Built $(LINUX_ARM64_BIN)"
# Build Darwin ARM64 (Mac)
$(DARWIN_ARM64_BIN): $(BUILD_DIR)
@echo "Building for Darwin ARM64..."
odin build . -target:darwin_arm64 -o:speed -out:$(DARWIN_ARM64_BIN)
@echo "Built $(DARWIN_ARM64_BIN)"
# Build all binaries
build-linux: $(LINUX_AMD64_BIN) # $(LINUX_ARM64_BIN)
build-darwin: $(DARWIN_ARM64_BIN)
# Compress Linux artifacts with gzip
$(BUILD_DIR)/$(APP_NAME)-$(VERSION)-linux-amd64.tar.gz: $(LINUX_AMD64_BIN)
@echo "Compressing Linux AMD64 artifact..."
cd $(BUILD_DIR) && tar -czf $(APP_NAME)-$(VERSION)-linux-amd64.tar.gz --transform 's|.*|$(APP_NAME)|' $(shell basename $(LINUX_AMD64_BIN))
$(BUILD_DIR)/$(APP_NAME)-$(VERSION)-linux-arm64.tar.gz: $(LINUX_ARM64_BIN)
@echo "Compressing Linux ARM64 artifact..."
cd $(BUILD_DIR) && tar -czf $(APP_NAME)-$(VERSION)-linux-arm64.tar.gz --transform 's|.*|$(APP_NAME)|' $(shell basename $(LINUX_ARM64_BIN))
# Compress Darwin artifacts with zip
$(BUILD_DIR)/$(APP_NAME)-$(VERSION)-darwin-arm64.zip: $(DARWIN_ARM64_BIN)
@echo "Compressing Darwin ARM64 artifact..."
cd $(BUILD_DIR) && cp $(shell basename $(DARWIN_ARM64_BIN)) $(APP_NAME) && zip $(APP_NAME)-$(VERSION)-darwin-arm64.zip $(APP_NAME) && rm $(APP_NAME)
# Compress all artifacts
compress: $(BUILD_DIR)/$(APP_NAME)-$(VERSION)-linux-amd64.tar.gz \
# $(BUILD_DIR)/$(APP_NAME)-$(VERSION)-linux-arm64.tar.gz \
# $(BUILD_DIR)/$(APP_NAME)-$(VERSION)-darwin-arm64.zip
# Build and compress all release artifacts
# release: build-linux build-darwin compress
release: build-linux compress
@echo "Release artifacts created:"
@ls -la $(BUILD_DIR)/*.tar.gz $(BUILD_DIR)/*.zip 2>/dev/null || echo "No compressed artifacts found"
# Clean binary files only
clean:
@echo "Cleaning binary files..."
@rm -f $(LINUX_AMD64_BIN) $(LINUX_ARM64_BIN) $(DARWIN_ARM64_BIN)
# Clean everything in build directory
cleanall:
@echo "Cleaning build directory..."
@rm -rf $(BUILD_DIR)
# Show available targets
help:
@echo "Available targets:"
@echo " all - Build all release artifacts (default)"
@echo " release - Build and compress all release artifacts"
@echo " build-linux - Build Linux binaries only"
@echo " build-darwin - Build Darwin binaries only"
@echo " compress - Compress all built binaries"
@echo " clean - Remove binary files only"
@echo " cleanall - Remove entire build directory"
@echo " help - Show this help message"
@echo ""
@echo "Release artifacts will be created in $(BUILD_DIR)/"
@echo "Version: $(VERSION)"

154
README.md
View File

@@ -3,7 +3,7 @@
Have you ever wanted to back up all your .env files in case your hard drive gets Have you ever wanted to back up all your .env files in case your hard drive gets
nuked? `envr` makes it easier. nuked? `envr` makes it easier.
`envr` is a [Nushell](https://www.nushell.sh) script that tracks your `.env` files `envr` is a binary application that tracks your `.env` files
in an encyrpted sqlite database. Changes can be effortlessly synced with in an encyrpted sqlite database. Changes can be effortlessly synced with
`envr sync`, and restored with `envr restore`. `envr sync`, and restored with `envr restore`.
@@ -12,65 +12,60 @@ the tool [of your choosing](#backup-options).
## Features ## Features
- 🔐 **Encrypted Storage**: All `.env` files are encrypted using your ssh key and - **Encrypted Storage**: All `.env` files are encrypted using your ssh key and
[age](https://github.com/FiloSottile/age) encryption. [libsodium](https://github.com/jedisct1/libsodium).
- 🔄 **Automatic Sync**: Update the database with one command, which can easily - **Automatic Sync**: Update the database with one command, which can easily
be run on a cron. be run on a cron.
- 🔍 **Smart Scanning**: Automatically discover and import `.env` files in your - **Smart Scanning**: Automatically discover and import `.env` files in your
home directory. home directory.
- 📝 **Multiple Config Formats**: Support for many configuration formats, - **Rename Detection**: Automatically find and updates renamed/moved
including: JSON, TOML, YAML, INI, XML, and NUON. repositories.
- [ ] TODO: 🗂️ **Rename Detection**: Automatically handle renamed repositories.
-**Interactive CLI**: User-friendly prompts for file selection and management
thanks to [nushell](https://www.nushell.sh/)
## TODOS ## TODOS
- [x] Rename Detection: automatically update moved files.
- [ ] Allow configuration of ssh key. - [ ] Allow use of keys from `ssh-agent`
- [ ] Allow multiple ssh keys. - [x] Allow configuration of ssh key.
- [x] Allow multiple ssh keys.
## Prerequisites
- An SSH key pair (for encryption/decryption)
- The following binaries:
- [nushell](https://www.nushell.sh/)
- [age](https://github.com/FiloSottile/age)
- [fd](https://github.com/sharkdp/fd)
- [sqlite3](https://github.com/sqlite/sqlite)
## Installation ## Installation
1. Clone the repository: You will need an SSH key pair for encryption and decryption. You can generate one
```bash with `ssh-keygen -t ed25519`. It will be saved to `~/.ssh/id_ed25519`.
git clone https://github.com/username/envr.git
cd envr ### With Odin
```
2. Install [dependencies](#prerequisites). If you already have `odin` installed:
3. Configure nushell.
```bash
# You'll need libsodium and sqlite
odin build -o:speed
envr init
```
### With Nix
If you are a [nix](https://nixos.org/) user
#### Try it out
```bash
nix run github.com:sbrow/envr --
```
#### Install it
```nix
# /etc/nixos/configuration.nix
{ config, envr, system, ... }: {
environment.systemPackages = [
envr.packages.${system}.default
];
}
```
## Quick Start ## Quick Start
1. **Initialize envr**: Check out the [man page](./docs/cli/envr.md) for the quick setup guide.
```bash
envr init
```
This will create your configuration file and set up encrypted storage.
2. **Scan for existing .env files**:
```bash
envr scan
```
Select files you want to back up from the interactive list.
3. **List tracked files**:
```bash
envr list
```
4. **Sync your environment files**:
```bash
envr sync
```
## Disclaimers ## Disclaimers
@@ -79,44 +74,57 @@ thanks to [nushell](https://www.nushell.sh/)
## Commands ## Commands
| Command | Description | See [the docs](./docs/cli) for the current list of available commands.
|---------|-------------|
| `envr init [format]` | Initialize envr with configuration file |
| `envr backup <file>` | Back up a specific .env file |
| `envr restore [path]` | Restore a backed-up .env file |
| `envr list` | View all tracked environment files |
| `envr scan` | Search for and selectively back up .env files |
| `envr sync` | Synchronize all tracked files (backup changes, restore missing) |
| `envr remove [...paths]` | Remove files from backup storage |
| `envr edit config` | Edit your configuration file |
| `envr config show` | Display current configuration |
## Configuration ## Configuration
The configuration file is created during initialization and supports multiple formats: The configuration file is created during initialization:
```toml ```jsonc
# Example ~/.envr/config.toml # Example ~/.envr/config.json
source = "~/.envr/config.toml" {
priv_key = "~/.ssh/id_ed25519" "keys": [
pub_key = "~/.ssh/id_ed25519.pub" {
"private": "/home/ubuntu/.ssh/id_ed25519",
[scan] "public": "/home/ubuntu/.ssh/id_ed25519.pub"
matcher = "\.env" }
exclude = "*.envrc" ],
include = "~" "scan": {
"matcher": "\\.env",
"exclude": [
"*\\.envrc",
"\\.local/",
"node_modules",
"vendor"
],
"include": "~"
}
}
``` ```
## Backup Options ## Backup Options
`envr` merely gathers your `.env` files in one local place. It is up to you to `envr` merely gathers your `.env` files in one local place. It is up to you to
back up the database (found at ~/.envr/data.age) to a *secure* and *remote* back up the database (found at `~/.envr/data.envr`) to a *secure* and *remote*
location. location.
### Git ### Git
`envr` preserves inodes when updating the database, so you can safely hardlink
`~/.envr/data.envr` into your [GNU Stow](https://www.gnu.org/software/stow/),
[Home Manager](https://github.com/nix-community/home-manager), or
[NixOS](https://nixos.wiki/wiki/flakes) repository.
> [!CAUTION]
> For **maximum security**, only save your `data.envr` file to a local
(i.e. non-cloud) git server that **you personally control**.
>
> I take no responsibility if you push all your secrets to a public GitHub repo.
### restic ### restic
[restic](https://restic.readthedocs.io/en/latest/010_introduction.html).
## License ## License
This project is licensed under the [MIT License](./LICENSE). This project is licensed under the [MIT License](./LICENSE).

268
TABLE_IMPROVEMENT_PLAN.md Normal file
View File

@@ -0,0 +1,268 @@
# Table Rendering Memory Optimization Plan
## Executive Summary
This plan outlines improvements to eliminate excessive memory allocations and copies in the Odin table rendering system. The current implementation makes 10+ allocations per row, while the Zig equivalent makes zero allocations for rendering. This optimization will reduce memory usage, improve performance, and align with the project's efficiency goals.
## Current State Analysis
### Zig Version (Reference Implementation)
- **Allocations**: 1 (data only)
- **Data copies**: 0
- **String allocation**: 0
- **Column widths**: Stack array
- **Output**: Direct to writer
### Odin Version (Current Implementation)
- **Allocations**: 10+ per row
- **Data copies**: Multiple per row
- **String allocation**: 2+ per row (concatenate + slice)
- **Column widths**: Heap allocated
- **Output**: Builder → stdout
### Current Issues Identified
1. **Table Infrastructure** (`table.odin`)
- Uses `strings.Builder` which allocates per-line memory
- Heap-allocated `[dynamic]int` for column widths
- Multiple `strings.concatenate()` calls creating new strings
2. **Command Implementations**
- `cmd_list`: Creates intermediate `[]string` slices per row, allocates new strings via `strings.concatenate()`
- `cmd_sync`: Creates `SyncEntry` structs with cloned strings, allocates dynamic arrays
- `cmd_deps`: Allocates dynamic rows array unnecessarily
3. **Memory Pattern**
- Each command allocates `[][]string` for table data
- Manual struct-to-row transformation creates copies
- Duplicate code across all table-using commands
## Proposed Solutions
### Phase 1: Core Table Infrastructure Overhaul
#### 1.1 Direct Writer-Based Rendering
**Current:**
```odin
b: strings.Builder
strings.builder_init(&b)
// ... build table in builder
fmt.println(strings.to_string(b))
```
**Proposed:**
```odin
render_table :: proc(writer: io.Writer, headers: []string, rows: [][]string)
```
- Replace `strings.Builder` with `io.Writer` output
- Eliminate intermediate string allocations
- Write table components directly to output stream
#### 1.2 Stack-Based Column Widths
**Current:**
```odin
col_widths := make([dynamic]int, 0, len(headers))
```
**Proposed:**
- Use fixed stack arrays for reasonable column counts
- Implement small buffer optimization (SBO) for variable column counts
- Only allocate for tables exceeding threshold (e.g., 16 columns)
#### 1.3 Zero-Copy String Handling
**Current:**
```odin
dir_str := strings.concatenate({row.Dir, "/"}, context.temp_allocator)
```
**Proposed:**
- Replace `strings.concatenate()` with string slicing
- Work directly with `EnvFile.Path` and `EnvFile.Dir` fields
- Use `filepath.base()` and `filepath.dir()` without allocation where possible
### Phase 2: Generic Table Interface
#### 2.1 Field-Based Table Renderer
```odin
Table_Field :: struct {
name: string,
value: string, // String view, no allocation
alignment: Alignment,
}
Table_Config :: struct {
writer: io.Writer,
fields: []Table_Field,
col_widths: []int,
}
render_row :: proc(cfg: Table_Config, row_data: any)
```
- Accept struct fields directly without intermediate arrays
- Support field selection (show only specific fields)
- Alignment options (left/center/right)
#### 2.2 Field Extraction Procs
- Generate field extraction helpers for each struct type
- Avoid string allocation by returning string views
- Cache computed values (like formatted status strings)
#### 2.3 Streaming Table Processing
- Process rows one at a time without collecting all rows
- Reduce peak memory usage from O(N × strings) to O(table_structure)
- Enable early termination if needed
### Phase 3: Command-Specific Optimizations
#### 3.1 Eliminate Intermediate Structs
**Current (cmd_sync):**
```odin
for &file in files {
// ... processing
path_str, _ := strings.clone(file.Path)
status_str, _ := strings.clone(status)
append(&results, SyncEntry{Path = path_str, Status = status_str})
}
```
**Proposed:**
```odin
for &file in files {
result, err_msg := db_sync(&db, &file)
// Direct rendering with zero-copy
render_sync_row(writer, file, result, err_msg)
}
```
- `cmd_sync`: Work directly with `EnvFile` + `SyncFlagEnum`
- `cmd_list`: Use `EnvFile` fields directly, no `ListEntry`
- Generate table content on-the-fly
#### 3.2 In-Place Status Computation
```odin
get_sync_status :: proc(result: SyncFlag, err_msg: string) -> string {
switch {
case .Error in result: return if len(err_msg) > 0 then err_msg else "error"
case .BackedUp in result: return "Backed Up"
case .Restored in result: return "Restored"
case .DirUpdated in result: return "Moved"
case: return "OK"
}
}
```
- Compute status strings without allocation (use static lookup)
- Cache formatted status values if needed
- Reduce allocation count from N to 0 or 1
#### 3.3 Batch Processing
- Reduce allocation count by pooling small allocations
- Use `context.temp_allocator` more effectively
- Pre-allocate buffers for expected sizes
### Phase 4: JSON Output Separation
#### 4.1 Unified JSON Rendering
```odin
render_json_rows :: proc(writer: io.Writer, rows: any, field_names: []string)
```
- Create centralized JSON rendering helper
- Work with same structs as table rendering
- Use reflection or explicit field marshaling
#### 4.2 Format-Agnostic Interface
- Commands generate data → renderers handle format
- Table renderer focuses only on ASCII/Unicode output
- Keep terminal detection in command layer
## Expected Improvements
| Metric | Current | Target | Improvement |
|--------|---------|--------|-------------|
| **Allocations** | 10+ per row | 0-1 per table | 10x+ reduction |
| **Memory copies** | 2-3 per row | 0 | 100% reduction |
| **Peak memory** | O(N × strings) | O(table_structure) | Constant factor |
| **Throughput** | Baseline | 2-3x faster | Performance boost |
## Implementation Strategy
### High-Priority Changes
1. Replace `strings.Builder` with direct `io.Writer` output
2. Convert column widths to stack-based allocation
3. Eliminate intermediate struct allocations in commands
### Medium-Priority Changes
1. Create generic field-based table interface
2. Implement streaming table processing
3. Centralize JSON rendering logic
### Low-Priority Changes
1. Add alignment options beyond left-aligned
2. Implement comprehensive field introspection
3. Add advanced table formatting features
## Tradeoff Questions
Before implementation begins, we need to resolve these architectural questions:
### 1. Generality vs. Performance
**Question:** Should we create a fully generic table renderer (similar to Zig's `Table(T)`) or focus on optimizing the current 3 use cases first?
**Options:**
- **Generic approach**: Higher development cost, future-proof, may have some overhead
- **Specific optimization**: Faster implementation, maximum performance for current use cases, less flexible
**Recommendation:** Start with specific optimizations for current use cases, then generalize patterns that emerge.
### 2. Alignment Support
**Question:** Does the project need left/center/right alignment support, or is left-alignment sufficient?
**Context:** Zig supports alignment options, but current Odin implementation only left-aligns. Most CLI tables work fine with left alignment.
**Recommendation:** Start with left-alignment only, add alignment if specific use cases demand it.
### 3. API Compatibility
**Question:** Should we maintain the current `render_table()` API signature, or are breaking changes acceptable?
**Current API:**
```odin
render_table :: proc(headers: []string, rows: [][]string)
```
**Options:**
- **Maintain API**: Slower to implement, backward compatible, may need adapter layers
- **Break API**: Faster implementation, cleaner code, requires updates to all callers
**Recommendation:** Breaking changes are acceptable since this is an optimization-focused effort and callers are limited to 3 commands.
### 4. Odin Capabilities
**Question:** What runtime reflection or field introspection capabilities does Odin provide?
**Context:** Zig uses `@typeInfo()` and comptime field iteration. We need to understand Odin's equivalent capabilities to design the optimal solution.
**Recommendation:** Investigate Odin's runtime type information capabilities before finalizing the generic table interface design.
### 5. Testing Strategy
**Question:** Should we add comprehensive tests for new table rendering before optimizing commands, or optimize incrementally with tests added afterwards?
**Options:**
- **Test-first**: More robust, catches regressions early, slower initial development
- **Optimize-first**: Faster development, may miss edge cases, requires retroactive testing
**Recommendation:** Hybrid approach - add basic tests for core infrastructure, then optimize incrementally with additional tests for each command.
## Next Steps
1. **Research Phase**: Investigate Odin's type system and reflection capabilities
2. **Prototype Phase**: Create minimal working prototype of zero-allocation table renderer
3. **Refactor Phase**: Incrementally update commands to use new infrastructure
4. **Test Phase**: Add comprehensive tests and verify memory improvements
5. **Benchmark Phase**: Measure performance improvements and memory usage
## Success Criteria
- [ ] Zero allocations for table rendering (excluding initial data)
- [ ] Zero string copies in the happy path
- [ ] All 3 commands (`list`, `sync`, `deps`) use new infrastructure
- [ ] Performance improvement of 2x or more
- [ ] Memory usage reduction of 50% or more
- [ ] No regression in table formatting quality
- [ ] Backward compatibility with JSON output format

63
TEST_PLAN.md Normal file
View File

@@ -0,0 +1,63 @@
# Test Coverage Plan
## Current State
- 104 tests, all passing
- Strong coverage: crypto, ssh, db CRUD + env_file + update_dir, config save/load + paths, scan, features, cant_scan, parse_args, `-c`/`--config-file` flag
- Misleading test files: `cmd_check_test`, `cmd_list_test`, `cmd_nushell_completion_test` don't test their namesake procs
- Biggest remaining gap: all `cmd_*` handlers untested
## Command handler tests
Stdout will be captured by redirecting `os.stdout` to a pipe.
### `cmd_version` (cmd_version.odin)
- Test default output (prints VERSION)
### `cmd_list` (cmd_list.odin)
- Test TTY path: fixture DB with rows, capture table output
- Test non-TTY path: capture JSON output, unmarshal and verify keys/values
- Test empty DB: verify clean output (empty table or `[]`)
### `cmd_backup` (cmd_backup.odin)
- Test successful backup: valid path, verify `db_insert` called
- Test missing file: verify error message
- Test duplicate backup: verify rejection or update behavior
### `cmd_remove` (cmd_remove.odin)
- Test successful removal: existing entry, verify `db_delete` called
- Test removal of non-existent entry: verify error or no-op
### `cmd_restore` (cmd_restore.odin)
- Test successful restore: entry exists in DB, verify file written to correct path
- Test restore of missing entry: verify error
- Test directory creation: restore to path with missing parent dirs
## Hard to test (interactive / external deps)
### `cmd_scan` (cmd_scan.odin)
- Test with fixture git repo containing `.env` files
- Test `find_unbacked` integration (already partially tested in `cmd_check_test.odin`)
- Non-TTY JSON output path
### `cmd_edit_config` (cmd_edit_config.odin)
- Needs refactoring: extract `$EDITOR` parsing into testable helper (TODO 12)
- Test multi-word editor values (`"code -w"`)
- Test missing `$EDITOR`
### `cmd_init` (cmd_init.odin)
- Interactive prompt makes this hard
- Needs refactoring: extract SSH key discovery and config generation into testable procs
- Test `--force` flag behavior
### `prompt.odin`
- Needs refactoring to be testable
- `render_options` could be tested if it accepted an `io.Writer`
- `read_key` could be tested with a pipe/redirect instead of raw stdin
- `multi_select` is end-to-end interactive, likely integration test only
## Notes
- DB integration tests should use in-memory SQLite (`:memory:`) where possible.
- Temp dir fixtures should follow the pattern in `scan_test.odin`.
- Tests that manipulate the `HOME` env var must use a mutex to prevent races with parallel test execution.

91
TODOS.md Normal file
View File

@@ -0,0 +1,91 @@
# TODOs
1. Consider giving db its own allocator
27. Commands are still leaking.
2. Generate md and man pages again.
3. **db.odin:324-327** — Map iteration (`remote_set`) is non-deterministic. Same file can produce different JSON on each backup, causing spurious DB diffs. Sort remotes before storing.
4. Make sure official path separators are used when appropriate, rather than '/'.
5. **cmd_restore.odin:20-30 & cmd_remove.odin:19-29** — Identical path-resolution block copy-pasted. `is_abs` guard is redundant since `filepath.abs` is a no-op on absolute paths. Extract a helper.
6. **cmd_restore.odin:44**`os.mkdir_all` error silently discarded. Subsequent write failure will be confusing.
8. **config.odin:178**`search_paths` silently ignores `os.user_home_dir` error. If home is empty, `~` isn't expanded. Same class of bug as issue 3.
10. **db.odin:115**`json.unmarshal_string` error not checked. Malformed JSON silently produces empty/partial data.
11. **db.odin:352-353**`hex.encode` error ignored. `string(hex_bytes)` aliases the byte slice.
12. **cmd_sync.odin:80, cmd_list.odin:33**`make([]string, 2)` for table rows never freed. Leaks per row. Defer to memory pass.
13. **cmd_list.odin** — Non-TTY branch builds `ListEntry` structs and marshals JSON separately. Now that `render_json_rows` (issue 1) accepts an `io.Writer` and uses `json.marshal`, unify both branches to use it. Note: will change JSON keys from `"directory"/"path"` to `"Directory"/"Path"`.
14. Check for prealloc opportunities. i.e. `make([dynamic]string)` -> `make([dynamic]string, 5)`.
15. Add a text filter to the multi_select.
17. Add tests for untested commands.
18. 2 scan tests silently skip when fd isn't installed, tests pass without actually testing anything. These should use #assert to be sure that fd is in path.
20. add --format -f flag to commands that draw tables.
21. Replace `testing.expect` calls with `testing.expect_value` calls where appropriate.
22. Change struct field names from PascalCase to snake_case.
23. procedures should be ordered by use, main at the top, then in the order they are called from main.
24. Shell completion
25. Bring back windows support / cross-compilation.
26. Test all cmds / terminal branches.
## Double-check AI output
- [ ] cli.odin
- [ ] cli_test.odin
- [x] cmd_backup.odin
- [x] cmd_check.odin
- [ ] cmd_check_test.odin
- [ ] cmd_edit_config.odin
- [x] cmd_init.odin
- [x] cmd_list.odin
- [ ] cmd_list_test.odin
- [x] cmd_nushell_completion.odin
- [x] cmd_nushell_completion_test.odin
- [x] cmd_remove.odin
- [x] cmd_restore.odin
- [x] cmd_scan.odin
- [x] cmd_sync.odin
- [x] cmd_version.odin
- [ ] config.odin
- [ ] config_test.odin
- [ ] crypto.odin
- [ ] crypto_test.odin
- [ ] db.odin
- [ ] db_integration_test.odin
- [ ] db_test.odin
- [x] main.odin
- [x] prompt.odin
- [ ] scan.odin
- [ ] scan_test.odin
- [ ] sodium.odin
- [ ] sqlite/sqlite.odin
- [ ] ssh.odin
- [ ] ssh_test.odin
- [ ] table.odin
- [ ] table_test.odin
- [ ] findr/findr_test.odin
- [ ] findr/gitignore.odin
- [ ] findr/gitignore_test.odin
- [ ] findr/glob.odin
- [ ] findr/glob_test.odin
- [ ] findr/repos.odin
- [ ] findr/test_env.odin
- [ ] findr/walker.odin

266
cli.odin Normal file
View File

@@ -0,0 +1,266 @@
package main
import "core:bufio"
import "core:fmt"
import "core:io"
import "core:os"
import "core:strings"
Command :: struct {
name: string,
args: [dynamic]string,
flags: map[string]string,
bool_set: map[string]bool,
config_path: string,
out_buf: ^bufio.Writer,
out: io.Writer,
err: io.Writer,
}
CommandInfo :: struct {
name: string,
usage: string,
short: string,
long: string,
aliases: []string,
}
COMMANDS := []CommandInfo {
{
"init",
"envr init",
"Set up envr",
`The init command generates your initial config and saves it to
~/.envr/config in JSON format.\n\nDuring setup, you will be prompted to select one or more ssh keys with which to
encrypt your databse. **Make 100% sure** that you have **a remote copy** of this
key somewhere, otherwise your data could be lost forever.`,
{},
},
{"scan", "envr scan", "Find and select .env files for backup", "", {}},
{"sync", "envr sync", "Update or restore your env backups", "", {}},
{"backup", "envr backup <path>", "Import a .env file into envr", "", {"add"}},
{"restore", "envr restore <path>", "Restore a .env file from the database", "", {}},
{"list", "envr list", "View your tracked files", "", {}},
{"remove", "envr remove <path>", "Remove a .env file from your database", "", {}},
{"check", "envr check [path]", "Check if files are backed up", "", {}},
{"version", "envr version", "Show envr's version", "", {}},
{"edit-config", "envr edit-config", "Edit your config with your default editor", "", {}},
{
"nushell-completion",
"envr nushell-completion",
"Generate custom completions for nushell",
"",
{},
},
}
// Caller is responsible for calling delete_command(cmd).
// FIXME: Works in kinda a wonky and awkward way.
parse_args :: proc(args: []string, out: io.Stream, err: io.Stream) -> (cmd: Command, ok: bool) {
{
cmd.out_buf = new(bufio.Writer)
bufio.writer_init(cmd.out_buf, out, allocator = context.allocator)
cmd.out = bufio.writer_to_writer(cmd.out_buf)
cmd.err = err
}
if len(args) < 2 || args[1] == "--help" || args[1] == "-h" {
write_usage(cmd.out)
return cmd, false
}
cmd.name = args[1]
cmd.args = make([dynamic]string)
cmd.flags = make(map[string]string)
cmd.bool_set = make(map[string]bool)
i := 2
for i < len(args) {
arg := args[i]
if strings.starts_with(arg, "--") {
key := arg[2:]
if i + 1 < len(args) && !strings.starts_with(args[i + 1], "-") {
cmd.flags[key] = args[i + 1]
i += 2
} else {
cmd.bool_set[key] = true
i += 1
}
} else if strings.starts_with(arg, "-") && len(arg) == 2 {
key_slice := arg[1:2]
if i + 1 < len(args) && !strings.starts_with(args[i + 1], "-") {
cmd.flags[key_slice] = args[i + 1]
i += 2
} else {
cmd.bool_set[key_slice] = true
i += 1
}
} else {
append(&cmd.args, arg)
i += 1
}
}
if val, ok := cmd.flags["config-file"]; ok {
cmd.config_path = val
} else if val, ok := cmd.flags["c"]; ok {
cmd.config_path = val
} else {
// FIXME: Handle err
// TODO: Is this right?
home, _ := os.user_home_dir(context.temp_allocator)
// TODO: should we copy out of the temp_allocator?
cmd.config_path = default_config_path(home, context.temp_allocator)
}
if has_flag(&cmd, "help") {
print_command_help(&cmd)
return cmd, false
}
return cmd, true
}
print_command_help :: proc(cmd: ^Command) {
ok := write_command_help(cmd.name, cmd.out)
if !ok {
fmt.wprintf(cmd.err, "Unknown command: %s\n", cmd.name)
write_usage(cmd.out)
}
}
write_command_help :: proc(name: string, w: io.Writer) -> bool {
info, found := find_command(name)
if !found {
return false
}
fmt.wprintf(w, "Usage: %s [flags]\n\n", info.usage, flush = false)
fmt.wprintf(w, "%s\n", info.short, flush = false)
if len(info.aliases) > 0 {
fmt.wprintf(w, "\nAliases:\n %s", info.name, flush = false)
for a in info.aliases {
fmt.wprintf(w, ", %s", a, flush = false)
}
fmt.wprintf(w, "\n", flush = false)
}
if len(info.long) > 0 {
fmt.wprintf(w, "\n%s\n", info.long, flush = false)
}
fmt.wprintf(
w,
"\nFlags:\n -h, --help help for %s\n -c, --config-file <path> config file (default \"~/.envr/config.json\")\n",
info.name,
flush = false,
)
return true
}
find_command :: proc(name: string) -> (CommandInfo, bool) {
for c in COMMANDS {
if c.name == name {
return c, true
}
for a in c.aliases {
if a == name {
return c, true
}
}
}
return CommandInfo{}, false
}
// TODO: command args should be shown in usage.
write_usage :: proc(w: io.Writer) {
fmt.wprintf(
w,
`envr keeps your .env synced to a local, age encrypted database.
Is a safe and easy way to gather all your .env files in one place where they can
easily be backed by another tool such as restic or git.
All your data is stored in ~/data.age
Getting started is easy:
1. Create your configuration file and set up encrypted storage:
> envr init
2. Scan for existing .env files:
> envr scan
Select the files you want to back up from the interactive list.
3. Verify that it worked:
> envr list
4. After changing any of your .env files, update the backup with:
> envr sync
5. If you lose a repository, after re-cloning the repo into the same path it was
at before, restore your backup with:
> envr restore ~/<path to repository>/.env
Usage:
envr [command]
Available Commands:
`,
flush = false,
)
for c in COMMANDS {
name_start := len(c.name)
fmt.wprintf(w, "%s", c.name, flush = false)
for a in c.aliases {
fmt.wprintf(w, ", %s", a, flush = false)
name_start += len(a) + 2
}
padding := 20 - name_start
if padding > 0 {
for _ in 0 ..< padding {
io.write_byte(w, ' ')
}
}
fmt.wprintf(w, " %s\n", c.short, flush = false)
}
fmt.wprintf(
w,
`
Flags:
-h, --help help for envr
-c, --config-file <path> config file (default "~/.envr/config.json")
Use "envr [command] --help" for more information about a command.
`,
flush = false,
)
}
has_flag :: proc(cmd: ^Command, name: string) -> bool {
_, ok := cmd.flags[name]
if ok {
return true
}
_, ok2 := cmd.bool_set[name]
return ok2
}
delete_command :: proc(cmd: ^Command) {
bufio.writer_flush(cmd.out_buf)
delete(cmd.args)
delete(cmd.flags)
delete(cmd.bool_set)
bufio.writer_destroy(cmd.out_buf)
free(cmd.out_buf)
}

370
cli_test.odin Normal file
View File

@@ -0,0 +1,370 @@
#+feature dynamic-literals
package main
import "core:bufio"
import "core:fmt"
import "core:strings"
import "core:testing"
@(test)
test_usage_text_contains_all_commands :: proc(t: ^testing.T) {
b: strings.Builder
strings.builder_init(&b)
defer strings.builder_destroy(&b)
write_usage(strings.to_writer(&b))
text := strings.to_string(b)
for c in COMMANDS {
testing.expect(
t,
strings.contains(text, c.name),
fmt.tprintf("usage missing command %q", c.name),
)
for a in c.aliases {
testing.expect(t, strings.contains(text, a), fmt.tprintf("usage missing alias %q", a))
}
}
}
@(test)
test_usage_text_contains_steps :: proc(t: ^testing.T) {
b: strings.Builder
strings.builder_init(&b)
defer strings.builder_destroy(&b)
write_usage(strings.to_writer(&b))
text := strings.to_string(b)
testing.expect(t, strings.contains(text, "1."), "missing step 1")
testing.expect(t, strings.contains(text, "2."), "missing step 2")
testing.expect(t, strings.contains(text, "3."), "missing step 3")
testing.expect(t, strings.contains(text, "4."), "missing step 4")
testing.expect(t, strings.contains(text, "5."), "missing step 5")
testing.expect(t, strings.contains(text, "> envr sync\n"), "step 4 missing 'envr sync'")
testing.expect(t, strings.contains(text, "> envr restore"), "step 5 missing 'envr restore'")
}
@(test)
test_usage_text_contains_flags_and_help_hint :: proc(t: ^testing.T) {
b: strings.Builder
strings.builder_init(&b)
defer strings.builder_destroy(&b)
write_usage(strings.to_writer(&b))
text := strings.to_string(b)
testing.expect(t, strings.contains(text, "Flags:"), "missing Flags section")
testing.expect(t, strings.contains(text, "--help"), "missing --help flag")
testing.expect(t, strings.contains(text, "Use \"envr [command] --help\""), "missing help hint")
}
@(test)
test_command_help_backup :: proc(t: ^testing.T) {
b: strings.Builder
strings.builder_init(&b)
defer strings.builder_destroy(&b)
ok := write_command_help("backup", strings.to_writer(&b))
testing.expect(t, ok, "write_command_help(\"backup\") returned false")
text := strings.to_string(b)
testing.expect(t, strings.contains(text, "Usage:"), "missing Usage line")
testing.expect(t, strings.contains(text, "envr backup <path>"), "missing usage pattern")
testing.expect(t, strings.contains(text, "Aliases:"), "missing Aliases section")
testing.expect(t, strings.contains(text, "add"), "missing 'add' alias")
testing.expect(t, strings.contains(text, "Flags:"), "missing Flags section")
testing.expect(t, strings.contains(text, "--help"), "missing --help in flags")
}
@(test)
test_command_help_add_alias :: proc(t: ^testing.T) {
b: strings.Builder
strings.builder_init(&b)
defer strings.builder_destroy(&b)
ok := write_command_help("add", strings.to_writer(&b))
testing.expect(t, ok, "write_command_help(\"add\") returned false")
text := strings.to_string(b)
testing.expect(
t,
strings.contains(text, "envr backup <path>"),
"'add' alias should resolve to backup usage",
)
testing.expect(t, strings.contains(text, "Aliases:"), "missing Aliases section")
}
@(test)
test_command_help_init_no_aliases :: proc(t: ^testing.T) {
b: strings.Builder
strings.builder_init(&b)
defer strings.builder_destroy(&b)
ok := write_command_help("init", strings.to_writer(&b))
testing.expect(t, ok, "write_command_help(\"init\") returned false")
text := strings.to_string(b)
testing.expect(t, strings.contains(text, "Usage:"), "missing Usage line")
testing.expect(t, !strings.contains(text, "Aliases:"), "init should not have Aliases section")
testing.expect(t, strings.contains(text, "Flags:"), "missing Flags section")
testing.expect(t, strings.contains(text, "help for init"), "missing 'help for init'")
}
@(test)
test_command_help_unknown :: proc(t: ^testing.T) {
b: strings.Builder
strings.builder_init(&b)
defer strings.builder_destroy(&b)
ok := write_command_help("nonexistent", strings.to_writer(&b))
testing.expect(t, !ok, "write_command_help(\"nonexistent\") should return false")
text := strings.to_string(b)
testing.expect(t, len(text) == 0, "text should be empty for unknown command")
}
@(test)
test_command_help_version :: proc(t: ^testing.T) {
b: strings.Builder
strings.builder_init(&b)
defer strings.builder_destroy(&b)
ok := write_command_help("version", strings.to_writer(&b))
testing.expect(t, ok, "write_command_help(\"version\") returned false")
text := strings.to_string(b)
testing.expect(t, strings.contains(text, "Usage:"), "missing Usage line")
testing.expect(
t,
!strings.contains(text, "Aliases:"),
"version should not have Aliases section",
)
}
@(test)
test_has_flag_bool_set :: proc(t: ^testing.T) {
cmd := Command {
name = "test",
bool_set = map[string]bool{"force" = true},
}
defer delete(cmd.bool_set)
testing.expect(t, has_flag(&cmd, "force"), "should find flag in bool_set")
testing.expect(t, !has_flag(&cmd, "verbose"), "should not find missing flag")
}
@(test)
test_has_flag_value_map :: proc(t: ^testing.T) {
cmd := Command {
name = "test",
flags = map[string]string{"output" = "/tmp/out"},
}
defer delete(cmd.flags)
testing.expect(t, has_flag(&cmd, "output"), "should find flag in flags map")
testing.expect(t, !has_flag(&cmd, "force"), "should not find missing flag")
}
@(test)
test_has_flag_both_maps :: proc(t: ^testing.T) {
cmd := Command {
name = "test",
flags = map[string]string{"output" = "/tmp/out"},
bool_set = map[string]bool{"force" = true},
}
defer delete(cmd.flags)
defer delete(cmd.bool_set)
testing.expect(t, has_flag(&cmd, "output"), "should find in flags")
testing.expect(t, has_flag(&cmd, "force"), "should find in bool_set")
testing.expect(t, !has_flag(&cmd, "verbose"), "should not find missing flag")
}
@(test)
test_has_flag_empty_command :: proc(t: ^testing.T) {
cmd := Command {
name = "test",
}
testing.expect(t, !has_flag(&cmd, "anything"), "empty command should have no flags")
}
test_parse_args :: proc(
args: []string,
) -> (
cmd: Command,
ok: bool,
out_text: string,
err_text: string,
) {
out_b: strings.Builder
strings.builder_init(&out_b)
defer strings.builder_destroy(&out_b)
err_b: strings.Builder
strings.builder_init(&err_b)
defer strings.builder_destroy(&err_b)
cmd, ok = parse_args(args, strings.to_stream(&out_b), strings.to_stream(&err_b))
if ok {
bufio.writer_flush(cmd.out_buf)
out_text = strings.to_string(out_b)
err_text = strings.to_string(err_b)
}
return
}
@(test)
test_parse_args_bare_command :: proc(t: ^testing.T) {
cmd, ok, _, _ := test_parse_args([]string{"envr", "list"})
testing.expect(t, ok, "should succeed")
if !ok do return
defer delete_command(&cmd)
testing.expect_value(t, cmd.name, "list")
testing.expect_value(t, len(cmd.args), 0)
testing.expect_value(t, len(cmd.flags), 0)
testing.expect_value(t, len(cmd.bool_set), 0)
}
@(test)
test_parse_args_positional :: proc(t: ^testing.T) {
cmd, ok, _, _ := test_parse_args([]string{"envr", "backup", "/project/.env"})
defer delete_command(&cmd)
testing.expect(t, ok, "should succeed")
testing.expect(t, cmd.name == "backup")
testing.expect(t, len(cmd.args) == 1)
testing.expect(t, cmd.args[0] == "/project/.env")
}
@(test)
test_parse_args_long_flag_with_value :: proc(t: ^testing.T) {
cmd, ok, _, _ := test_parse_args([]string{"envr", "sync", "--config", "x.json"})
testing.expect(t, ok, "should succeed")
if !ok do return
defer delete_command(&cmd)
testing.expect(t, cmd.flags["config"] == "x.json")
}
@(test)
test_parse_args_short_flag_with_value :: proc(t: ^testing.T) {
cmd, ok, _, _ := test_parse_args([]string{"envr", "sync", "-c", "x.json"})
testing.expect(t, ok, "should succeed")
if !ok do return
defer delete_command(&cmd)
testing.expect(t, cmd.flags["c"] == "x.json")
}
@(test)
test_parse_args_long_bool_flag :: proc(t: ^testing.T) {
cmd, ok, _, _ := test_parse_args([]string{"envr", "init", "--force"})
testing.expect(t, ok, "should succeed")
if !ok do return
defer delete_command(&cmd)
testing.expect(t, cmd.bool_set["force"] == true)
}
@(test)
test_parse_args_short_bool_flag :: proc(t: ^testing.T) {
cmd, ok, _, _ := test_parse_args([]string{"envr", "version", "-l"})
testing.expect(t, ok, "should succeed")
if !ok do return
defer delete_command(&cmd)
testing.expect(t, cmd.bool_set["l"] == true)
}
@(test)
test_parse_args_multiple_positionals :: proc(t: ^testing.T) {
cmd, ok, _, _ := test_parse_args([]string{"envr", "backup", "a", "b"})
testing.expect(t, ok, "should succeed")
if !ok do return
defer delete_command(&cmd)
testing.expect(t, len(cmd.args) == 2)
testing.expect(t, cmd.args[0] == "a")
testing.expect(t, cmd.args[1] == "b")
}
@(test)
test_parse_args_mixed_flags_and_positionals :: proc(t: ^testing.T) {
cmd, ok, _, _ := test_parse_args([]string{"envr", "backup", "/project/.env", "--force"})
testing.expect(t, ok, "should succeed")
if !ok do return
defer delete_command(&cmd)
testing.expect(t, cmd.bool_set["force"] == true)
testing.expect(t, len(cmd.args) == 1)
testing.expect(t, cmd.args[0] == "/project/.env")
}
@(test)
test_parse_args_no_args :: proc(t: ^testing.T) {
cmd, ok, _, _ := test_parse_args([]string{"envr"})
defer delete_command(&cmd)
testing.expect(t, !ok, "no args should return false")
}
@(test)
test_parse_args_flag_then_positional_then_flag :: proc(t: ^testing.T) {
cmd, ok, _, _ := test_parse_args([]string{"envr", "backup", "a.env", "--force", "--verbose"})
defer delete_command(&cmd)
testing.expect(t, ok, "should succeed")
testing.expect(t, cmd.bool_set["force"] == true)
testing.expect(t, cmd.bool_set["verbose"] == true)
testing.expect(t, len(cmd.args) == 1)
testing.expect(t, cmd.args[0] == "a.env")
}
@(test)
test_parse_args_config_file_long_flag :: proc(t: ^testing.T) {
cmd, ok, _, _ := test_parse_args(
[]string{"envr", "list", "--config-file", "/custom/config.json"},
)
testing.expect(t, ok, "should succeed")
if !ok do return
defer delete_command(&cmd)
testing.expect(
t,
cmd.config_path == "/custom/config.json",
"config_path should be set from --config-file",
)
}
@(test)
test_parse_args_config_file_short_flag :: proc(t: ^testing.T) {
cmd, ok, _, _ := test_parse_args([]string{"envr", "list", "-c", "/custom/config.json"})
testing.expect(t, ok, "should succeed")
if !ok do return
defer delete_command(&cmd)
testing.expect(
t,
cmd.config_path == "/custom/config.json",
"config_path should be set from -c",
)
}
@(test)
test_parse_args_config_file_defaults :: proc(t: ^testing.T) {
cmd, ok, _, _ := test_parse_args([]string{"envr", "list"})
testing.expect(t, ok, "should succeed")
if !ok do return
defer delete_command(&cmd)
testing.expect(t, len(cmd.config_path) > 0, "config_path should default to non-empty path")
testing.expect(
t,
strings.contains(cmd.config_path, ".envr"),
"default config_path should contain .envr dir, got %s",
)
}

35
cmd_backup.odin Normal file
View File

@@ -0,0 +1,35 @@
package main
import "core:fmt"
import "core:strings"
cmd_backup :: proc(cmd: ^Command) {
if len(cmd.args) != 1 {
print_command_help(cmd)
return
}
path := cmd.args[0]
if len(strings.trim_space(path)) == 0 {
fmt.wprintln(cmd.err, "Error: No path provided", flush = false)
return
}
file, ok := new_env_file(path)
if !ok {
return
}
db, db_ok := db_open(cmd.config_path)
if !db_ok {
return
}
defer db_close(&db)
if !db_insert(&db, file) {
return
}
fmt.wprintf(cmd.out, "Saved %s into the database\n", path, flush = false)
}

85
cmd_check.odin Normal file
View File

@@ -0,0 +1,85 @@
package main
import "core:fmt"
import "core:os"
import "core:path/filepath"
cmd_check :: proc(cmd: ^Command) {
check_path: string
if len(cmd.args) > 0 {
check_path = cmd.args[0]
} else {
cwd, cwd_err := os.get_working_directory(context.temp_allocator)
if cwd_err != nil {
fmt.wprintf(cmd.err, "Error getting current directory: %v\n", cwd_err, flush = false)
return
}
check_path = cwd
}
abs_path: string
if filepath.is_abs(check_path) {
abs_path = check_path
} else {
resolved, abs_err := filepath.abs(check_path)
if abs_err != nil {
fmt.wprintf(cmd.err, "Error getting absolute path: %v\n", abs_err, flush = false)
return
}
abs_path = resolved
}
db, db_ok := db_open(cmd.config_path)
if !db_ok {
return
}
defer db_close(&db)
is_dir := os.is_directory(abs_path)
files_in_path: [dynamic]string
if is_dir {
scanned, scan_ok := scan_path(abs_path, db.cfg)
if !scan_ok {
fmt.wprintln(cmd.err, "Error scanning directory for .env files", flush = false)
return
}
files_in_path = scanned
} else {
append(&files_in_path, abs_path)
}
db_files, list_ok := db_list(&db)
if !list_ok {
return
}
defer delete(db_files)
defer for &file in db_files {delete_envfile(&file)}
not_backed := find_unbacked(files_in_path[:], db_files[:])
if len(not_backed) == 0 {
if len(files_in_path) == 0 {
fmt.wprintln(cmd.out, "No .env files found in the specified directory.", flush = false)
} else {
fmt.wprintln(
cmd.out,
"✓ All .env files in the directory are backed up.",
flush = false,
)
}
} else {
fmt.wprintf(
cmd.out,
"Found %d .env file(s) that are not backed up:\n",
len(not_backed),
flush = false,
)
for file in not_backed {
fmt.wprintf(cmd.out, " %s\n", file, flush = false)
}
fmt.wprintln(cmd.out, "\nRun 'envr sync' to back up these files.", flush = false)
}
}

48
cmd_check_test.odin Normal file
View File

@@ -0,0 +1,48 @@
package main
import "core:fmt"
import "core:testing"
@(test)
test_find_unbacked_finds_missing :: proc(t: ^testing.T) {
local := []string{"/a/.env", "/b/.env", "/c/.env"}
db := []EnvFile{{Path = "/a/.env"}, {Path = "/b/.env"}}
result := find_unbacked(local, db[:])
testing.expect(t, len(result) == 1, fmt.tprintf("expected 1 unbacked, got %d", len(result)))
if len(result) > 0 {
testing.expect(
t,
result[0] == "/c/.env",
fmt.tprintf("expected /c/.env, got %s", result[0]),
)
}
}
@(test)
test_find_unbacked_all_backed :: proc(t: ^testing.T) {
local := []string{"/a/.env", "/b/.env"}
db := []EnvFile{{Path = "/a/.env"}, {Path = "/b/.env"}}
result := find_unbacked(local, db[:])
testing.expect(t, len(result) == 0, fmt.tprintf("expected 0 unbacked, got %d", len(result)))
}
@(test)
test_find_unbacked_no_local :: proc(t: ^testing.T) {
local: []string
db := []EnvFile{{Path = "/a/.env"}}
result := find_unbacked(local, db[:])
testing.expect(t, len(result) == 0, fmt.tprintf("expected 0 unbacked, got %d", len(result)))
}
@(test)
test_find_unbacked_none_backed :: proc(t: ^testing.T) {
local := []string{"/a/.env", "/b/.env"}
db: []EnvFile
result := find_unbacked(local, db[:])
testing.expect(t, len(result) == 2, fmt.tprintf("expected 2 unbacked, got %d", len(result)))
}

49
cmd_edit_config.odin Normal file
View File

@@ -0,0 +1,49 @@
package main
import "core:fmt"
import "core:os"
cmd_edit_config :: proc(cmd: ^Command) {
editor := os.get_env("EDITOR", context.allocator)
if len(editor) == 0 {
fmt.wprintln(cmd.err, "Error: $EDITOR environment variable is not set", flush = false)
return
}
config_path := cmd.config_path
_, stat_err := os.stat(config_path, context.allocator)
if stat_err != nil {
fmt.wprintf(
cmd.err,
"Config file does not exist at %s. Run 'envr init' first.\n",
config_path,
flush = false,
)
return
}
args := []string{editor, config_path}
desc := os.Process_Desc {
command = args,
stdin = os.stdin,
stdout = os.stdout,
stderr = os.stderr,
}
p, start_err := os.process_start(desc)
if start_err != nil {
fmt.wprintf(cmd.err, "Error running editor: %v\n", start_err, flush = false)
return
}
state, wait_err := os.process_wait(p)
if wait_err != nil {
fmt.wprintf(cmd.err, "Error waiting for editor: %v\n", wait_err, flush = false)
return
}
if state.exit_code != 0 {
os.exit(int(state.exit_code))
}
}

62
cmd_init.odin Normal file
View File

@@ -0,0 +1,62 @@
package main
import "core:fmt"
cmd_init :: proc(cmd: ^Command) {
force := has_flag(cmd, "force") || has_flag(cmd, "f")
fmt.wprintln(cmd.out, cmd.config_path, flush = false)
_, cfg_exists := load_config(cmd.config_path)
if cfg_exists && !force {
fmt.wprintln(
cmd.out,
`You have already initialized envr.
Run again with the --force flag if you want to reinitialize.`,
flush = false,
)
return
}
keys, ok := find_ssh_private_keys()
if !ok {
return
}
if len(keys) == 0 {
fmt.wprintln(cmd.err, `No ssh-ed25519 keys found in ~/.ssh
Generate one with: ssh-keygen -t ed25519`, flush = false)
return
}
selected, result := multi_select("Select SSH private keys:", keys[:])
defer delete(selected)
if result == .Cancel {
fmt.wprintln(cmd.out, "\x1b[2mCancelled.\x1b[0m", flush = false)
return
}
selected_paths := make([dynamic]string, 0, min(1, len(keys) / 2))
for i in 0 ..< len(keys) {
if selected[i] {
append(&selected_paths, keys[i])
}
}
if len(selected_paths) == 0 {
fmt.wprintln(cmd.err, "No SSH keys selected - Config not created", flush = false)
return
}
cfg := new_config(selected_paths[:], cmd.config_path)
if !save_config(cfg, force = force) {
return
}
fmt.wprintf(
cmd.out,
"Config initialized with %d SSH key(s). You are ready to use envr.\n",
len(selected_paths),
flush = false,
)
}

67
cmd_list.odin Normal file
View File

@@ -0,0 +1,67 @@
package main
import "core:encoding/json"
import "core:fmt"
import "core:os"
import "core:path/filepath"
import "core:strings"
import "core:terminal"
ListEntry :: struct {
Directory: string `json:"directory"`,
Path: string `json:"path"`,
}
// TODO: Support --format flag
// TODO: Improve table rendering
cmd_list :: proc(cmd: ^Command) {
db, db_ok := db_open(cmd.config_path)
if !db_ok {
return
}
defer db_close(&db)
rows, list_ok := db_list(&db)
if !list_ok {
return
}
defer delete(rows)
defer for &row in rows {delete_envfile(&row)}
if terminal.is_terminal(os.stdout) {
headers := []string{"Directory", "Path"}
table_rows := make([dynamic][]string, 0, len(rows), context.temp_allocator)
for row in rows {
dir_str := strings.concatenate({row.Dir, "/"}, context.temp_allocator)
filename := filepath.base(row.Path)
row_slice := make([]string, 2, context.temp_allocator)
row_slice[0] = dir_str
row_slice[1] = filename
append(&table_rows, row_slice)
}
render_table(cmd.out, headers, table_rows[:])
} else {
// TODO: Should we instead print full entries here?
entries: [dynamic]ListEntry
for row in rows {
filename := filepath.base(row.Path)
append(
&entries,
ListEntry {
Directory = strings.concatenate({row.Dir, "/"}, context.temp_allocator),
Path = filename,
},
)
}
data, marshal_err := json.marshal(entries[:], allocator = context.temp_allocator)
if marshal_err != nil {
fmt.wprintf(cmd.err, "Error marshaling JSON: %v\n", marshal_err, flush = false)
return
}
fmt.wprintln(cmd.out, string(data), flush = false)
}
}

18
cmd_list_test.odin Normal file
View File

@@ -0,0 +1,18 @@
package main
import "core:path/filepath"
import "core:testing"
@(test)
test_filepath_base_equals_rel :: proc(t: ^testing.T) {
cases := []string{"/home/user/.env", "/home/user/project/.envrc", "/tmp/foo", "/a/b/c/d.txt"}
for path in cases {
dir := filepath.dir(path)
rel, rel_err := filepath.rel(dir, path, context.temp_allocator)
testing.expect(t, rel_err == nil, "filepath.rel returned an error")
base := filepath.base(path)
testing.expect(t, rel == base, "filepath.rel(dir, path) should equal filepath.base(path)")
}
}

View File

@@ -0,0 +1,10 @@
package main
import "core:fmt"
COMPLETION_SCRIPT: string : string(#load("mod.nu"))
cmd_nushell_completion :: proc(cmd: ^Command) {
fmt.wprint(cmd.out, COMPLETION_SCRIPT, flush = false)
}

View File

@@ -0,0 +1,36 @@
package main
import "core:fmt"
import "core:strings"
import "core:testing"
@(test)
test_nushell_completion_nonempty :: proc(t: ^testing.T) {
testing.expect(t, len(COMPLETION_SCRIPT) > 0, "completion script should not be empty")
}
@(test)
test_nushell_completion_contains_externs :: proc(t: ^testing.T) {
expected := []string{
"tracked-paths",
"untracked-paths",
"envr backup",
"envr check",
"envr edit-config",
"envr help",
"envr init",
"envr list",
"envr remove",
"envr restore",
"envr scan",
"envr sync",
"envr nushell-completion",
}
for ext in expected {
testing.expect(
t,
strings.contains(COMPLETION_SCRIPT, ext),
fmt.tprintf("expected script to contain %q", ext),
)
}
}

44
cmd_remove.odin Normal file
View File

@@ -0,0 +1,44 @@
package main
import "core:fmt"
import "core:path/filepath"
import "core:strings"
cmd_remove :: proc(cmd: ^Command) {
if len(cmd.args) != 1 {
print_command_help(cmd)
return
}
path := cmd.args[0]
if len(strings.trim_space(path)) == 0 {
fmt.wprintln(cmd.err, "Error: No path provided", flush = false)
return
}
// TODO: Is this the best way to do it?
abs_path: string
if filepath.is_abs(path) {
abs_path = path
} else {
resolved, abs_err := filepath.abs(path)
if abs_err != nil {
fmt.wprintf(cmd.err, "Error getting absolute path: %v\n", abs_err, flush = false)
return
}
abs_path = resolved
}
db, db_ok := db_open(cmd.config_path)
if !db_ok {
return
}
defer db_close(&db)
if !db_delete(&db, abs_path) {
return
}
fmt.wprintf(cmd.out, "Removed %s from the database\n", abs_path, flush = false)
}

55
cmd_restore.odin Normal file
View File

@@ -0,0 +1,55 @@
package main
import "core:fmt"
import "core:os"
import "core:path/filepath"
import "core:strings"
cmd_restore :: proc(cmd: ^Command) {
if len(cmd.args) != 1 {
print_command_help(cmd)
return
}
path := cmd.args[0]
if len(strings.trim_space(path)) == 0 {
fmt.wprintln(cmd.err, "Error: No path provided", flush = false)
return
}
// TODO: Is this the right way to handle this?
abs_path: string
if filepath.is_abs(path) {
abs_path = path
} else {
resolved, abs_err := filepath.abs(path)
if abs_err != nil {
fmt.wprintf(cmd.err, "Error getting absolute path: %v\n", abs_err, flush = false)
return
}
abs_path = resolved
}
db, db_ok := db_open(cmd.config_path)
if !db_ok {
return
}
defer db_close(&db)
file, fetch_ok := db_fetch(&db, abs_path)
if !fetch_ok {
return
}
dir := filepath.dir(file.Path)
os.mkdir_all(dir)
write_err := os.write_entire_file(file.Path, file.contents)
if write_err != nil {
fmt.wprintf(cmd.err, "Error writing file: %v\n", write_err, flush = false)
return
}
fmt.wprintf(cmd.out, "Restored %s\n", file.Path, flush = false)
}

100
cmd_scan.odin Normal file
View File

@@ -0,0 +1,100 @@
package main
import "core:encoding/json"
import "core:fmt"
import "core:os"
import "core:terminal"
cmd_scan :: proc(cmd: ^Command) {
db, db_ok := db_open(cmd.config_path)
if !db_ok {
return
}
defer db_close(&db)
search_dirs := search_paths(db.cfg)
if len(search_dirs) == 0 {
fmt.wprintln(
cmd.err,
"No search paths configured. Please run `envr init -f` or edit your config.",
flush = false,
)
return
}
// TODO: Figure out a sane default
all_files: [dynamic]string
for dir in search_dirs {
found, scan_ok := scan_path(dir, db.cfg)
if !scan_ok {
fmt.wprintf(cmd.err, "Error scanning %s\n", dir, flush = false)
continue
}
for f in found {
append(&all_files, f)
}
}
db_files, list_ok := db_list(&db)
if !list_ok {
return
}
files := find_unbacked(all_files[:], db_files[:])
if len(files) == 0 {
fmt.wprintln(cmd.out, "No .env files found to add.", flush = false)
return
}
if !terminal.is_terminal(os.stdout) {
output, marshal_err := json.marshal(files[:])
if marshal_err != nil {
fmt.wprintf(
cmd.err,
"Error marshaling files to JSON: %v\n",
marshal_err,
flush = false,
)
return
}
fmt.wprintln(cmd.out, string(output), flush = false)
return
}
selected, result := multi_select("Select .env files to backup:", files[:])
defer delete(selected)
if result == .Cancel {
fmt.wprintln(cmd.out, "\x1b[2mCancelled.\x1b[0m", flush = false)
return
}
added_count: int
for i in 0 ..< len(files) {
if !selected[i] {
continue
}
env_file, ok := new_env_file(files[i])
if !ok {
fmt.wprintf(cmd.err, "Error reading %s\n", files[i], flush = false)
continue
}
if !db_insert(&db, env_file) {
fmt.wprintf(cmd.err, "Error adding %s\n", files[i], flush = false)
continue
}
added_count += 1
}
if added_count > 0 {
fmt.wprintf(
cmd.out,
"\x1b[1;32mSuccessfully added %d file(s) to backup.\x1b[0m\n",
added_count,
flush = false,
)
} else {
fmt.wprintln(cmd.out, "\x1b[2mNo files were added.\x1b[0m", flush = false)
}
}

96
cmd_sync.odin Normal file
View File

@@ -0,0 +1,96 @@
package main
import "core:encoding/json"
import "core:fmt"
import "core:os"
import "core:strings"
import "core:terminal"
SyncEntry :: struct {
Path: string `json:"path"`,
Status: string `json:"status"`,
}
// TODO: Check for quiet failures.
// TODO: Support --format -f flags
cmd_sync :: proc(cmd: ^Command) {
db, db_ok := db_open(cmd.config_path)
if !db_ok {
return
}
defer db_close(&db)
files, list_ok := db_list(&db)
if !list_ok {
return
}
defer delete(files)
// TODO: Set sane default size
results: [dynamic]SyncEntry
defer delete(results)
for &file in files {
old_path: string
old_path, _ = strings.clone(file.Path, context.temp_allocator)
result, err_msg := db_sync(&db, &file)
status: string
is_dir_updated := .DirUpdated in result
switch {
case .Error in result:
if len(err_msg) > 0 {
status = err_msg
} else {
status = "error"
}
case .BackedUp in result:
status = "Backed Up"
case .Restored in result:
status = "Restored"
case .DirUpdated in result:
status = "Moved"
case:
status = "OK"
}
if is_dir_updated {
if !db_delete(&db, old_path) {
return
}
}
if db_update_required(result) {
if !db_insert(&db, file) {
return
}
}
path_str, _ := strings.clone(file.Path)
status_str, _ := strings.clone(status)
append(&results, SyncEntry{Path = path_str, Status = status_str})
}
if terminal.is_terminal(os.stdout) {
headers := []string{"File", "Status"}
table_rows := make([dynamic][]string, 0, len(results))
for res in results {
row_slice := make([]string, 2)
row_slice[0] = res.Path
row_slice[1] = res.Status
append(&table_rows, row_slice)
}
render_table(cmd.out, headers, table_rows[:])
} else {
data, marshal_err := json.marshal(results[:])
if marshal_err != nil {
fmt.wprintf(cmd.err, "Error marshaling JSON: %v\n", marshal_err, flush = false)
return
}
fmt.wprintln(cmd.out, string(data), flush = false)
}
}

10
cmd_version.odin Normal file
View File

@@ -0,0 +1,10 @@
package main
import "core:fmt"
VERSION :: #load("version.txt", string)
cmd_version :: proc(cmd: ^Command) {
fmt.wprintln(cmd.out, VERSION, flush = false)
}

228
config.odin Normal file
View File

@@ -0,0 +1,228 @@
package main
import "core:encoding/json"
import "core:fmt"
import "core:os"
import "core:path/filepath"
import "core:strings"
import "findr"
SshKeyPair :: struct {
Private: string `json:"private"`,
Public: string `json:"public"`,
}
ScanConfig :: struct {
Matcher: string `json:"matcher"`,
Exclude: [dynamic]string `json:"exclude"`,
Include: [dynamic]string `json:"include"`,
}
Config :: struct {
Keys: [dynamic]SshKeyPair `json:"keys"`,
ScanConfig: ScanConfig `json:"scan"`,
config_path: string `json:"-"`,
}
load_config :: proc(config_path: string) -> (Config, bool) {
data, read_err := os.read_entire_file_from_path(config_path, context.allocator)
if read_err != nil {
fmt.println("No config file found. Please run `envr init` to generate one.")
return Config{}, false
}
defer delete(data)
cfg: Config
// TODO: use json 5
err := json.unmarshal(data, &cfg)
if err != nil {
fmt.printf("Error parsing config: %v\n", err)
return Config{}, false
}
cfg.config_path = config_path
return cfg, true
}
default_config_path :: proc(home: string, allocator := context.allocator) -> string {
path, err := filepath.join([]string{home, ".envr", "config.json"}, allocator)
if err != nil {
panic("Ran out of memory when building config path")
}
return path
}
delete_config :: proc(cfg: ^Config) {
for key in cfg.Keys {
delete(key.Private)
delete(key.Public)
}
delete(cfg.Keys)
delete(cfg.ScanConfig.Matcher)
for exclude in cfg.ScanConfig.Exclude {
delete(exclude)
}
delete(cfg.ScanConfig.Exclude)
for include in cfg.ScanConfig.Include {
delete(include)
}
delete(cfg.ScanConfig.Include)
}
save_config :: proc(cfg: Config, force: bool = false) -> bool {
config_dir := envr_dir(cfg.config_path)
if !os.exists(config_dir) {
mkdir_err := os.make_directory(config_dir)
if mkdir_err != nil {
fmt.printf("Error creating %s directory: %v\n", config_dir, mkdir_err)
return false
}
}
if os.exists(cfg.config_path) && !force {
info, stat_err := os.stat(cfg.config_path, context.allocator)
if stat_err == nil {
defer os.file_info_delete(info, context.allocator)
if info.size > 0 {
fmt.println("Config file already exists. Run again with --force to reinitialize.")
return false
}
}
}
data, marshal_err := json.marshal(cfg, {pretty = true, use_spaces = true, spaces = 2})
if marshal_err != nil {
fmt.printf("Error marshaling config: %v\n", marshal_err)
return false
}
defer delete(data)
write_err := os.write_entire_file(cfg.config_path, data)
if write_err != nil {
fmt.printf("Error writing config: %v\n", write_err)
return false
}
return true
}
// Caller is responsible for calling delete_config()
new_config :: proc(
private_key_paths: []string,
cfg_path: string = "~/.envr/config.json",
) -> Config {
keys := make([dynamic]SshKeyPair, 0, len(private_key_paths))
for priv in private_key_paths {
// TODO: Is this bad?
priv_key := strings.clone(priv)
pub, _ := strings.concatenate([]string{priv_key, ".pub"})
append(&keys, SshKeyPair{Private = priv_key, Public = pub})
}
exclude := make([dynamic]string, 0, 4)
append(&exclude, strings.clone("*\\.envrc"))
append(&exclude, strings.clone("\\.local/"))
append(&exclude, strings.clone("node_modules"))
append(&exclude, strings.clone("vendor"))
include := make([dynamic]string, 0, 1)
append(&include, strings.clone("~"))
scan_cfg := ScanConfig {
Matcher = strings.clone("\\.env"),
Exclude = exclude,
Include = include,
}
return Config{Keys = keys, ScanConfig = scan_cfg, config_path = cfg_path}
}
find_ssh_private_keys :: proc() -> (keys: [dynamic]string, ok: bool) {
home, home_err := os.user_home_dir(context.allocator)
if home_err != nil {
fmt.printf("Error getting home dir: %v\n", home_err)
return
}
ssh_dir, join_err := filepath.join([]string{home, ".ssh"})
if join_err != nil {
fmt.printf("Error building ssh path: %v\n", join_err)
return
}
entries, dir_err := os.read_all_directory_by_path(ssh_dir, context.allocator)
if dir_err != nil {
fmt.printf("Could not read ~/.ssh directory: %v\n", dir_err)
return
}
defer os.file_info_slice_delete(entries, context.allocator)
for entry in entries {
name := entry.name
if entry.type == .Directory {
continue
}
if strings.has_suffix(name, ".pub") {
continue
}
if strings.contains(name, "known_hosts") {
continue
}
if strings.contains(name, "config") {
continue
}
full_path, _ := filepath.join([]string{ssh_dir, name})
if !is_ed25519_key(full_path) {
continue
}
append(&keys, full_path)
}
ok = true
return
}
find_git_roots :: proc(cfg: Config) -> (roots: [dynamic]string, ok: bool) {
paths := search_paths(cfg)
findr.find_repos(paths[:], &roots, os.get_processor_core_count())
ok = true
return
}
search_paths :: proc(cfg: Config) -> (paths: [dynamic]string) {
// TODO: Is this okay?
// TODO: handle error
home, _ := os.user_home_dir(context.temp_allocator)
for include in cfg.ScanConfig.Include {
// TODO: Do we need to manually expand ~/ in odin?
expanded, _ := strings.replace(include, "~", home, 1)
if filepath.is_abs(expanded) {
append(&paths, expanded)
} else {
defer delete(expanded)
resolved, err := filepath.abs(expanded)
if err == nil {
append(&paths, resolved)
}
}
}
return
}
envr_dir :: proc(config_path: string) -> string {
return filepath.dir(config_path)
}
// User is responsible for freeing the path
data_path :: proc(config_path: string, allocator := context.allocator) -> string {
path, _ := filepath.join([]string{envr_dir(config_path), "data.envr"}, allocator)
return path
}

210
config_test.odin Normal file
View File

@@ -0,0 +1,210 @@
package main
import "core:fmt"
import "core:os"
import "core:path/filepath"
import "core:strings"
import "core:sync"
import "core:testing"
home_mutex: sync.Mutex
@(test)
test_new_config_single_key :: proc(t: ^testing.T) {
paths := []string{"/home/user/.ssh/id_ed25519"}
cfg := new_config(paths)
defer delete_config(&cfg)
testing.expect(t, len(cfg.Keys) == 1, "should have 1 key")
testing.expect(t, cfg.Keys[0].Private == "/home/user/.ssh/id_ed25519", "Private path mismatch")
testing.expect(
t,
cfg.Keys[0].Public == "/home/user/.ssh/id_ed25519.pub",
"Public path mismatch",
)
}
@(test)
test_new_config_multiple_keys :: proc(t: ^testing.T) {
paths := []string{"/home/user/.ssh/id_ed25519", "/home/user/.ssh/id_rsa"}
cfg := new_config(paths)
defer delete_config(&cfg)
testing.expect(t, len(cfg.Keys) == 2, "should have 2 keys")
testing.expect(t, cfg.Keys[0].Private == "/home/user/.ssh/id_ed25519")
testing.expect(t, cfg.Keys[1].Private == "/home/user/.ssh/id_rsa")
}
@(test)
test_new_config_empty_keys :: proc(t: ^testing.T) {
paths: []string
cfg := new_config(paths)
defer delete_config(&cfg)
testing.expect(t, len(cfg.Keys) == 0, "should have 0 keys")
}
@(test)
test_new_config_scan_defaults :: proc(t: ^testing.T) {
paths := []string{"/home/user/.ssh/id_ed25519"}
cfg := new_config(paths)
defer delete_config(&cfg)
testing.expect(t, cfg.ScanConfig.Matcher == "\\.env", "matcher should be \\.env")
testing.expect(t, len(cfg.ScanConfig.Exclude) == 4, "should have 4 exclude patterns")
testing.expect(t, len(cfg.ScanConfig.Include) == 1, "should have 1 include path")
testing.expect(t, cfg.ScanConfig.Include[0] == "~", "include should be ~")
}
@(test)
test_new_config_exclude_patterns :: proc(t: ^testing.T) {
paths := []string{"/home/user/.ssh/id_ed25519"}
cfg := new_config(paths)
defer delete_config(&cfg)
expected := []string{"*\\.envrc", "\\.local/", "node_modules", "vendor"}
for i in 0 ..< len(expected) {
testing.expect(t, cfg.ScanConfig.Exclude[i] == expected[i])
}
}
@(test)
test_save_load_config_roundtrip :: proc(t: ^testing.T) {
base := fmt.tprintf("/tmp/envr-test-cfg-rt-%d", os.get_pid())
os.mkdir_all(base)
defer os.remove_all(base)
cfgPath, err := filepath.join([]string{base, "config.json"}, context.temp_allocator)
testing.expect(t, err == nil, "cfgPath should build successfully")
cfg := new_config([]string{"/home/user/.ssh/id_ed25519"}, cfgPath)
defer delete_config(&cfg)
testing.expect(t, save_config(cfg, force = true), "save should succeed")
loaded, ok := load_config(cfg.config_path)
testing.expect(t, ok, "load should succeed")
if !ok do return
defer delete_config(&loaded)
testing.expect(t, len(loaded.Keys) == 1, "should have 1 key")
testing.expect(t, loaded.Keys[0].Private == "/home/user/.ssh/id_ed25519")
testing.expect(t, loaded.Keys[0].Public == "/home/user/.ssh/id_ed25519.pub")
testing.expect(t, loaded.ScanConfig.Matcher == "\\.env")
testing.expect(t, len(loaded.ScanConfig.Exclude) == 4)
testing.expect(t, len(loaded.ScanConfig.Include) == 1)
testing.expect(t, loaded.ScanConfig.Include[0] == "~")
}
@(test)
test_load_config_missing :: proc(t: ^testing.T) {
_, ok := load_config("/tmp/envr-test-cfg-nonexistent/config.json")
testing.expect(t, !ok, "missing config should return false")
}
@(test)
test_save_config_no_clobber :: proc(t: ^testing.T) {
base := fmt.tprintf("/tmp/envr-test-cfg-noclobber-%d", os.get_pid())
os.mkdir_all(base)
defer os.remove_all(base)
cfgPath, err := filepath.join([]string{base, "config.json"}, context.temp_allocator)
testing.expect(t, err == nil, "cfgPath should build successfully")
cfg := new_config([]string{"/home/user/.ssh/key1"}, cfgPath)
defer delete_config(&cfg)
testing.expect(t, save_config(cfg, force = true), "first save should succeed")
cfg2 := new_config([]string{"/home/user/.ssh/key2"}, cfgPath)
defer delete_config(&cfg2)
testing.expect(t, !save_config(cfg2), "second save without force should fail")
}
@(test)
test_save_config_force_overwrites :: proc(t: ^testing.T) {
base := fmt.tprintf("/tmp/envr-test-cfg-force-%d", os.get_pid())
os.mkdir_all(base)
defer os.remove_all(base)
cfgPath, err := filepath.join([]string{base, "config.json"}, context.temp_allocator)
testing.expect(t, err == nil, "cfgPath should build successfully")
cfg := new_config([]string{"/home/user/.ssh/key1"}, cfgPath)
defer delete_config(&cfg)
testing.expect(t, save_config(cfg, force = true), "first save should succeed")
cfg2 := new_config([]string{"/home/user/.ssh/key2"}, cfgPath)
defer delete_config(&cfg2)
testing.expect(t, save_config(cfg2, force = true), "force save should overwrite")
loaded, ok := load_config(cfgPath)
testing.expect(t, ok, "load should succeed")
if !ok do return
defer delete_config(&loaded)
testing.expect(t, len(loaded.Keys) == 1, "should have 1 key")
testing.expect(
t,
loaded.Keys[0].Private == "/home/user/.ssh/key2",
"should be the overwritten key",
)
}
@(test)
test_envr_dir :: proc(t: ^testing.T) {
dir := envr_dir("/tmp/envr-fake-home-envrdir/.envr/config.json")
testing.expectf(t, strings.has_suffix(dir, ".envr"), "dir should end with .envr, got %s", dir)
testing.expectf(
t,
strings.contains(dir, "envr-fake-home-envrdir"),
"dir should contain home dir, got %s",
dir,
)
}
@(test)
test_data_path :: proc(t: ^testing.T) {
p := data_path("/tmp/envr-fake-home-datapath/config.json")
defer delete(p)
testing.expectf(t, strings.has_suffix(p, "data.envr"), "should end with data.envr, got %s", p)
testing.expectf(t, strings.contains(p, ".envr"), "should contain .envr dir, got %s", p)
}
@(test)
test_search_paths_expands_tilde :: proc(t: ^testing.T) {
sync.mutex_lock(&home_mutex)
defer sync.mutex_unlock(&home_mutex)
old_home := os.get_env("HOME", context.temp_allocator)
defer {
if old_home != "" {
os.set_env("HOME", old_home)
}
}
os.set_env("HOME", "/tmp/envr-fake-home-search")
cfg := Config {
ScanConfig = ScanConfig{Include = make([dynamic]string, 0, 1)},
}
defer delete(cfg.ScanConfig.Include)
append(&cfg.ScanConfig.Include, "~")
paths := search_paths(cfg)
defer delete(paths)
for path in paths {
defer delete(path)
}
testing.expect(t, len(paths) == 1, "should have 1 path")
if len(paths) > 0 {
testing.expectf(
t,
strings.contains(paths[0], "envr-fake-home-search"),
"should expand ~ to home, got %s",
paths[0],
)
testing.expect(t, !strings.contains(paths[0], "~"), "should not contain literal ~")
}
}

323
crypto.odin Normal file
View File

@@ -0,0 +1,323 @@
package main
import "core:fmt"
import "core:mem"
import "core:os"
MAGIC :: "ENVR"
MAGIC_BYTES := [4]u8{u8('E'), u8('N'), u8('V'), u8('R')}
RECIPIENT_ENTRY_SIZE ::
CRYPTO_BOX_PUBLICKEY_BYTES +
CRYPTO_BOX_NONCE_BYTES +
CRYPTO_SECRETBOX_KEY_BYTES +
CRYPTO_BOX_MAC_BYTES
HEADER_SIZE :: 4 + CRYPTO_BOX_PUBLICKEY_BYTES + CRYPTO_SECRETBOX_NONCE_BYTES + 4
RecipientEntry :: struct {
PublicKey: [CRYPTO_BOX_PUBLICKEY_BYTES]u8,
Nonce: [CRYPTO_BOX_NONCE_BYTES]u8,
EncryptedKey: [CRYPTO_SECRETBOX_KEY_BYTES + CRYPTO_BOX_MAC_BYTES]u8,
}
X25519Keypair :: struct {
Public: [CRYPTO_BOX_PUBLICKEY_BYTES]u8,
Private: [CRYPTO_BOX_SECRETKEY_BYTES]u8,
}
@(init)
init_sodium :: proc "contextless" () {
if sodium_init() < 0 {
os.exit(1)
}
}
encrypt :: proc(plaintext: []u8, keys: []SshKeyPair) -> (ciphertext: []u8, ok: bool) {
x25519_pairs, pairs_ok := ssh_to_x25519(keys)
if !pairs_ok {
return
}
defer delete(x25519_pairs)
sym_key: [CRYPTO_SECRETBOX_KEY_BYTES]u8
randombytes_buf(&sym_key[0], CRYPTO_SECRETBOX_KEY_BYTES)
main_nonce: [CRYPTO_SECRETBOX_NONCE_BYTES]u8
randombytes_buf(&main_nonce[0], CRYPTO_SECRETBOX_NONCE_BYTES)
ct_len := len(plaintext) + CRYPTO_SECRETBOX_MAC_BYTES
secret_ct := make([]u8, ct_len)
pt_ptr: [^]u8
if len(plaintext) > 0 {
pt_ptr = &plaintext[0]
}
rc := crypto_secretbox_easy(
&secret_ct[0],
pt_ptr,
u64(len(plaintext)),
&main_nonce[0],
&sym_key[0],
)
if rc != 0 {
fmt.println("Error: symmetric encryption failed")
delete(secret_ct)
return
}
num_recipients := u32(len(x25519_pairs))
entries := make([]RecipientEntry, num_recipients)
for i in 0 ..< len(x25519_pairs) {
for j in 0 ..< CRYPTO_BOX_PUBLICKEY_BYTES {
entries[i].PublicKey[j] = x25519_pairs[i].Public[j]
}
randombytes_buf(&entries[i].Nonce[0], CRYPTO_BOX_NONCE_BYTES)
rc = crypto_box_easy(
&entries[i].EncryptedKey[0],
&sym_key[0],
CRYPTO_SECRETBOX_KEY_BYTES,
&entries[i].Nonce[0],
&x25519_pairs[i].Public[0],
&x25519_pairs[0].Private[0],
)
if rc != 0 {
fmt.printf("Error: failed to encrypt for recipient %d\n", i)
delete(entries)
delete(secret_ct)
return
}
}
total_len := HEADER_SIZE + int(num_recipients) * RECIPIENT_ENTRY_SIZE + ct_len
ciphertext = make([]u8, total_len)
pos := 0
mem.copy(&ciphertext[pos], &MAGIC_BYTES[0], 4)
pos += 4
mem.copy(&ciphertext[pos], &x25519_pairs[0].Public[0], CRYPTO_BOX_PUBLICKEY_BYTES)
pos += CRYPTO_BOX_PUBLICKEY_BYTES
mem.copy(&ciphertext[pos], &main_nonce[0], CRYPTO_SECRETBOX_NONCE_BYTES)
pos += CRYPTO_SECRETBOX_NONCE_BYTES
ciphertext[pos] = u8((num_recipients >> 24) & 0xff)
ciphertext[pos + 1] = u8((num_recipients >> 16) & 0xff)
ciphertext[pos + 2] = u8((num_recipients >> 8) & 0xff)
ciphertext[pos + 3] = u8(num_recipients & 0xff)
pos += 4
for i in 0 ..< int(num_recipients) {
mem.copy(&ciphertext[pos], &entries[i].PublicKey[0], CRYPTO_BOX_PUBLICKEY_BYTES)
pos += CRYPTO_BOX_PUBLICKEY_BYTES
mem.copy(&ciphertext[pos], &entries[i].Nonce[0], CRYPTO_BOX_NONCE_BYTES)
pos += CRYPTO_BOX_NONCE_BYTES
mem.copy(
&ciphertext[pos],
&entries[i].EncryptedKey[0],
CRYPTO_SECRETBOX_KEY_BYTES + CRYPTO_BOX_MAC_BYTES,
)
pos += CRYPTO_SECRETBOX_KEY_BYTES + CRYPTO_BOX_MAC_BYTES
}
mem.copy(&ciphertext[pos], &secret_ct[0], ct_len)
delete(entries)
delete(secret_ct)
ok = true
return
}
decrypt :: proc(ciphertext: []u8, keys: []SshKeyPair) -> (plaintext: []u8, ok: bool) {
if len(ciphertext) < HEADER_SIZE {
fmt.println("Error: ciphertext too short (header)")
return
}
for i in 0 ..< 4 {
if ciphertext[i] != MAGIC_BYTES[i] {
fmt.println("Error: invalid magic bytes")
return
}
}
offset := 4
sender_pk: [CRYPTO_BOX_PUBLICKEY_BYTES]u8
for i in 0 ..< CRYPTO_BOX_PUBLICKEY_BYTES {
sender_pk[i] = ciphertext[offset + i]
}
offset += CRYPTO_BOX_PUBLICKEY_BYTES
main_nonce: [CRYPTO_SECRETBOX_NONCE_BYTES]u8
for i in 0 ..< CRYPTO_SECRETBOX_NONCE_BYTES {
main_nonce[i] = ciphertext[offset + i]
}
offset += CRYPTO_SECRETBOX_NONCE_BYTES
num_recipients :=
u32(ciphertext[offset]) << 24 |
u32(ciphertext[offset + 1]) << 16 |
u32(ciphertext[offset + 2]) << 8 |
u32(ciphertext[offset + 3])
offset += 4
recipients_end := offset + int(num_recipients) * RECIPIENT_ENTRY_SIZE
if recipients_end > len(ciphertext) {
fmt.println("Error: ciphertext too short (recipient data)")
return
}
enc_sym_key: [CRYPTO_SECRETBOX_KEY_BYTES + CRYPTO_BOX_MAC_BYTES]u8
enc_nonce: [CRYPTO_BOX_NONCE_BYTES]u8
enc_pub: [CRYPTO_BOX_PUBLICKEY_BYTES]u8
x25519_pairs, pairs_ok := ssh_to_x25519(keys)
if !pairs_ok {
return
}
defer delete(x25519_pairs)
found := false
matched_pi := 0
for pi in 0 ..< len(x25519_pairs) {
scan_offset := offset
for _ in 0 ..< int(num_recipients) {
for i in 0 ..< CRYPTO_BOX_PUBLICKEY_BYTES {
enc_pub[i] = ciphertext[scan_offset + i]
}
scan_offset += CRYPTO_BOX_PUBLICKEY_BYTES
match := true
for i in 0 ..< CRYPTO_BOX_PUBLICKEY_BYTES {
if enc_pub[i] != x25519_pairs[pi].Public[i] {
match = false
break
}
}
if !match {
scan_offset +=
CRYPTO_BOX_NONCE_BYTES + CRYPTO_SECRETBOX_KEY_BYTES + CRYPTO_BOX_MAC_BYTES
continue
}
for i in 0 ..< CRYPTO_BOX_NONCE_BYTES {
enc_nonce[i] = ciphertext[scan_offset + i]
}
scan_offset += CRYPTO_BOX_NONCE_BYTES
for i in 0 ..< CRYPTO_SECRETBOX_KEY_BYTES + CRYPTO_BOX_MAC_BYTES {
enc_sym_key[i] = ciphertext[scan_offset + i]
}
scan_offset += CRYPTO_SECRETBOX_KEY_BYTES + CRYPTO_BOX_MAC_BYTES
found = true
matched_pi = pi
break
}
if found {
break
}
}
if !found {
fmt.println("Error: no matching recipient found")
return
}
sym_key: [CRYPTO_SECRETBOX_KEY_BYTES]u8
rc := crypto_box_open_easy(
&sym_key[0],
&enc_sym_key[0],
CRYPTO_SECRETBOX_KEY_BYTES + CRYPTO_BOX_MAC_BYTES,
&enc_nonce[0],
&sender_pk[0],
&x25519_pairs[matched_pi].Private[0],
)
if rc != 0 {
fmt.println("Error: failed to decrypt symmetric key")
return
}
ct_data := ciphertext[recipients_end:]
pt_len := len(ct_data) - CRYPTO_SECRETBOX_MAC_BYTES
if pt_len < 0 {
fmt.println("Error: ciphertext too short (no encrypted data)")
return
}
plaintext = make([]u8, pt_len)
pt_ptr: [^]u8
if len(plaintext) > 0 {
pt_ptr = &plaintext[0]
}
rc = crypto_secretbox_open_easy(
pt_ptr,
&ct_data[0],
u64(len(ct_data)),
&main_nonce[0],
&sym_key[0],
)
if rc != 0 {
fmt.println("Error: symmetric decryption failed")
delete(plaintext)
return
}
ok = true
return
}
ssh_to_x25519 :: proc(keys: []SshKeyPair) -> (pairs: []X25519Keypair, ok: bool) {
if len(keys) == 0 {
return
}
pairs = make([]X25519Keypair, len(keys))
for i in 0 ..< len(keys) {
ssh_kp, parse_ok := parse_ssh_private_key(keys[i].Private)
if !parse_ok {
fmt.printf("Error: failed to parse SSH private key: %s\n", keys[i].Private)
delete(pairs)
return
}
ssh_pub, pub_ok := parse_ssh_public_key(keys[i].Public)
if !pub_ok {
fmt.printf("Error: failed to parse SSH public key: %s\n", keys[i].Public)
delete(pairs)
return
}
pk_rc := crypto_sign_ed25519_pk_to_curve25519(&pairs[i].Public[0], &ssh_pub[0])
if pk_rc != 0 {
fmt.println("Error: failed to convert ed25519 public key to curve25519")
delete(pairs)
return
}
ed25519_sk: [64]u8
for j in 0 ..< 32 {
ed25519_sk[j] = ssh_kp.Private[j]
}
for j in 0 ..< 32 {
ed25519_sk[32 + j] = ssh_kp.Public[j]
}
sk_rc := crypto_sign_ed25519_sk_to_curve25519(&pairs[i].Private[0], &ed25519_sk[0])
if sk_rc != 0 {
fmt.println("Error: failed to convert ed25519 private key to curve25519")
delete(pairs)
return
}
}
ok = true
return
}

134
crypto_test.odin Normal file
View File

@@ -0,0 +1,134 @@
package main
import "core:fmt"
import "core:testing"
CRYPTO_TEST_KEY_DIR :: "fixtures/keys"
make_test_key_pair :: proc(name: string) -> SshKeyPair {
priv := fmt.tprintf("%s/%s", CRYPTO_TEST_KEY_DIR, name)
pub := fmt.tprintf("%s/%s.pub", CRYPTO_TEST_KEY_DIR, name)
return SshKeyPair{Private = priv, Public = pub}
}
@(test)
test_encrypt_decrypt_roundtrip :: proc(t: ^testing.T) {
key := make_test_key_pair("test_ed25519")
original := []u8{1, 2, 3, 4, 5, 6, 7, 8, 9, 10}
encrypted, enc_ok := encrypt(original, []SshKeyPair{key})
testing.expect(t, enc_ok, "encryption should succeed")
testing.expect(t, len(encrypted) > 0, "ciphertext should not be empty")
defer delete(encrypted)
decrypted, dec_ok := decrypt(encrypted, []SshKeyPair{key})
testing.expect(t, dec_ok, "decryption should succeed")
defer delete(decrypted)
testing.expect(
t,
len(decrypted) == len(original),
fmt.tprintf("expected %d bytes, got %d", len(original), len(decrypted)),
)
for i in 0 ..< len(original) {
testing.expect(t, decrypted[i] == original[i], fmt.tprintf("byte mismatch at index %d", i))
}
}
@(test)
test_encrypt_decrypt_multi_recipient :: proc(t: ^testing.T) {
key1 := make_test_key_pair("test_ed25519")
key2 := make_test_key_pair("test_ed25519_second")
original := []u8{42, 43, 44, 45}
encrypted, enc_ok := encrypt(original, []SshKeyPair{key1, key2})
testing.expect(t, enc_ok, "encryption with 2 keys should succeed")
defer delete(encrypted)
decrypted1, dec1_ok := decrypt(encrypted, []SshKeyPair{key1})
testing.expect(t, dec1_ok, "decryption with key1 should succeed")
defer delete(decrypted1)
decrypted2, dec2_ok := decrypt(encrypted, []SshKeyPair{key2})
testing.expect(t, dec2_ok, "decryption with key2 should succeed")
defer delete(decrypted2)
for i in 0 ..< len(original) {
testing.expect(
t,
decrypted1[i] == original[i],
fmt.tprintf("key1: byte mismatch at %d", i),
)
testing.expect(
t,
decrypted2[i] == original[i],
fmt.tprintf("key2: byte mismatch at %d", i),
)
}
}
@(test)
test_decrypt_wrong_key_fails :: proc(t: ^testing.T) {
key1 := make_test_key_pair("test_ed25519")
key2 := make_test_key_pair("test_ed25519_second")
original := []u8{1, 2, 3}
encrypted, enc_ok := encrypt(original, []SshKeyPair{key1})
testing.expect(t, enc_ok, "encryption should succeed")
defer delete(encrypted)
_, dec_ok := decrypt(encrypted, []SshKeyPair{key2})
testing.expect(t, !dec_ok, "decryption with wrong key should fail")
}
@(test)
test_encrypt_empty_plaintext :: proc(t: ^testing.T) {
key := make_test_key_pair("test_ed25519")
original: []u8
encrypted, enc_ok := encrypt(original, []SshKeyPair{key})
testing.expect(t, enc_ok, "encryption of empty data should succeed")
defer delete(encrypted)
decrypted, dec_ok := decrypt(encrypted, []SshKeyPair{key})
testing.expect(t, dec_ok, "decryption should succeed")
defer delete(decrypted)
testing.expect(t, len(decrypted) == 0, "decrypted empty data should be empty")
}
@(test)
test_recipient_can_decrypt_senders_data :: proc(t: ^testing.T) {
key1 := make_test_key_pair("test_ed25519")
key2 := make_test_key_pair("test_ed25519_second")
original := []u8{10, 20, 30, 40, 50}
encrypted, enc_ok := encrypt(original, []SshKeyPair{key1, key2})
testing.expect(t, enc_ok, "encryption with 2 keys should succeed")
defer delete(encrypted)
decrypted, dec_ok := decrypt(encrypted, []SshKeyPair{key2})
testing.expect(t, dec_ok, "second recipient should decrypt without the sender key present")
defer delete(decrypted)
for i in 0 ..< len(original) {
testing.expect(t, decrypted[i] == original[i], fmt.tprintf("byte mismatch at %d", i))
}
}
@(test)
test_ciphertext_has_magic :: proc(t: ^testing.T) {
key := make_test_key_pair("test_ed25519")
original := []u8{1, 2, 3}
encrypted, enc_ok := encrypt(original, []SshKeyPair{key})
testing.expect(t, enc_ok, "encryption should succeed")
defer delete(encrypted)
testing.expect(t, len(encrypted) >= 4, "ciphertext should have at least 4 bytes")
testing.expect(t, encrypted[0] == u8('E'), "magic byte 0")
testing.expect(t, encrypted[1] == u8('N'), "magic byte 1")
testing.expect(t, encrypted[2] == u8('V'), "magic byte 2")
testing.expect(t, encrypted[3] == u8('R'), "magic byte 3")
}

580
db.odin Normal file
View File

@@ -0,0 +1,580 @@
package main
import "core:crypto/hash"
import "core:encoding/hex"
import "core:encoding/ini"
import "core:encoding/json"
import "core:fmt"
import "core:os"
import "core:path/filepath"
import "core:strings"
import "sqlite"
SyncFlagEnum :: enum {
Noop,
DirUpdated,
Restored,
BackedUp,
Error,
}
SyncFlag :: bit_set[SyncFlagEnum]
SyncDirection :: enum {
TrustDatabase,
TrustFilesystem,
}
Db :: struct {
// Pointer to the sqlite db
db: ^rawptr,
cfg: Config,
changed: bool,
}
EnvFile :: struct {
Path: string,
Dir: string,
Remotes: [dynamic]string,
Sha256: string,
contents: string,
}
delete_envfile :: proc(f: ^EnvFile) {
delete(f.Path)
for &remote in f.Remotes {
delete(remote)
}
delete(f.Remotes)
delete(f.Sha256)
delete(f.contents)
}
db_open :: proc(cfg_path: string) -> (database: Db, ok: bool) {
database.cfg = load_config(cfg_path) or_return
{
db: ^rawptr
rc := sqlite.db_open(":memory:", &db)
if rc != sqlite.OK {
fmt.printf("Error opening in-memory database: %s\n", sqlite.db_errmsg(db))
return
}
create_sql: cstring = "CREATE TABLE IF NOT EXISTS envr_env_files (path TEXT PRIMARY KEY NOT NULL, remotes TEXT, sha256 TEXT NOT NULL, contents TEXT NOT NULL)"
rc = sqlite.db_exec(db, create_sql, nil, nil, nil)
if rc != sqlite.OK {
fmt.printf("Error creating table: %s\n", sqlite.db_errmsg(db))
sqlite.db_close(db)
return
}
database.db = db
}
// TODO: Use different allocators?
data_path := data_path(database.cfg.config_path, context.temp_allocator)
if os.exists(data_path) {
if ok = db_restore_from_encrypted(&database, data_path); !ok {
sqlite.db_close(database.db)
return
}
} else {
// DB was created
database.changed = true
}
return database, true
}
db_restore_from_encrypted :: proc(db: ^Db, data_path: string) -> bool {
encrypted_data, read_err := os.read_entire_file_from_path(data_path, context.allocator)
defer delete(encrypted_data)
if read_err != nil {
fmt.printf("Error reading encrypted database: %v\n", read_err)
return false
}
plaintext, dec_ok := decrypt(encrypted_data, db.cfg.Keys[:])
if !dec_ok {
fmt.println("Error: decryption failed")
return false
}
defer delete(plaintext)
n := i64(len(plaintext))
buf := sqlite.malloc64(n)
if buf == nil {
fmt.println("Error: failed to allocate buffer for deserialization")
return false
}
copy(buf[:len(plaintext)], plaintext)
rc := sqlite.deserialize(
db.db,
"main",
buf,
n,
n,
sqlite.DESERIALIZE_FREEONCLOSE | sqlite.DESERIALIZE_RESIZEABLE,
)
if rc != sqlite.OK {
sqlite.free(buf)
fmt.printf("Error deserializing database: %s\n", sqlite.db_errmsg(db.db))
return false
}
return true
}
db_close :: proc(d: ^Db) {
defer sqlite.db_close(d.db)
defer delete_config(&d.cfg)
if d.changed {
rc := sqlite.db_exec(d.db, "VACUUM", nil, nil, nil)
if rc != sqlite.OK {
fmt.printf("Error vacuuming database: %s\n", sqlite.db_errmsg(d.db))
return
}
sz: i64
data := sqlite.serialize(d.db, "main", &sz, 0)
if data == nil {
fmt.println("Error: failed to serialize database")
return
}
defer sqlite.free(data)
sqlite_data := data[:sz]
encrypted, enc_ok := encrypt(sqlite_data, d.cfg.Keys[:])
if !enc_ok {
fmt.println("Error: encryption failed")
return
}
data_path := data_path(d.cfg.config_path)
envr_d := envr_dir(d.cfg.config_path)
os.mkdir_all(envr_d)
write_err := os.write_entire_file(data_path, encrypted)
delete(encrypted)
if write_err != nil {
fmt.printf("Error writing encrypted database: %v\n", write_err)
return
}
d.changed = false
}
}
db_list :: proc(d: ^Db, allocator := context.allocator) -> (results: [dynamic]EnvFile, ok: bool) {
stmt: ^rawptr
rc := sqlite.prepare_v2(
d.db,
"SELECT path, remotes, sha256, contents FROM envr_env_files",
-1,
&stmt,
nil,
)
if rc != sqlite.OK {
fmt.printf("Error preparing query: %s\n", sqlite.db_errmsg(d.db))
return
}
defer sqlite.finalize(stmt)
for {
rc = sqlite.step(stmt)
if rc == sqlite.DONE {
break
}
if rc != sqlite.ROW {
fmt.printf("Error stepping query: %s\n", sqlite.db_errmsg(d.db))
return
}
remotes_json := string(sqlite.column_text(stmt, 1))
remotes: [dynamic]string = ---
if len(remotes_json) > 0 {
json.unmarshal_string(remotes_json, &remotes, allocator = allocator)
}
path := clone_cstring(sqlite.column_text(stmt, 0), allocator)
append(
&results,
EnvFile {
Path = path,
Dir = filepath.dir(path),
Remotes = remotes,
Sha256 = clone_cstring(sqlite.column_text(stmt, 2), allocator),
contents = clone_cstring(sqlite.column_text(stmt, 3), allocator),
},
)
}
ok = true
return
}
db_insert :: proc(d: ^Db, file: EnvFile) -> bool {
remotes_json, marshal_err := json.marshal(file.Remotes)
if marshal_err != nil {
fmt.printf("Error marshaling remotes: %v\n", marshal_err)
return false
}
defer delete(remotes_json)
sql: cstring =
"INSERT OR REPLACE INTO " +
"envr_env_files (path, remotes, sha256, contents) VALUES (?, ?, ?, ?)"
stmt: ^rawptr
rc := sqlite.prepare_v2(d.db, sql, -1, &stmt, nil)
if rc != sqlite.OK {
fmt.printf("Error preparing insert: %s\n", sqlite.db_errmsg(d.db))
return false
}
defer sqlite.finalize(stmt)
// TODO: deal with elsewhere?
cpath := to_cstring(file.Path)
defer delete(cpath)
rc = sqlite.bind_text(stmt, 1, cpath, -1, nil)
if rc != sqlite.OK {
fmt.printf("Error binding path: %s\n", sqlite.db_errmsg(d.db))
return false
}
cremotes := to_cstring(string(remotes_json))
defer delete(cremotes)
rc = sqlite.bind_text(stmt, 2, cremotes, -1, nil)
if rc != sqlite.OK {
fmt.printf("Error binding remotes: %s\n", sqlite.db_errmsg(d.db))
return false
}
csha := to_cstring(file.Sha256)
defer delete(csha)
rc = sqlite.bind_text(stmt, 3, csha, -1, nil)
if rc != sqlite.OK {
fmt.printf("Error binding sha256: %s\n", sqlite.db_errmsg(d.db))
return false
}
ccontents := to_cstring(file.contents)
defer delete(ccontents)
rc = sqlite.bind_text(stmt, 4, ccontents, -1, nil)
if rc != sqlite.OK {
fmt.printf("Error binding contents: %s\n", sqlite.db_errmsg(d.db))
return false
}
rc = sqlite.step(stmt)
if rc != sqlite.DONE {
fmt.printf("Error inserting: %s\n", sqlite.db_errmsg(d.db))
return false
}
d.changed = true
return true
}
db_fetch :: proc(d: ^Db, path: string, allocator := context.allocator) -> (EnvFile, bool) {
sql: cstring = "SELECT path, remotes, sha256, contents FROM envr_env_files WHERE path = ?"
stmt: ^rawptr
rc := sqlite.prepare_v2(d.db, sql, -1, &stmt, nil)
if rc != sqlite.OK {
fmt.printf("Error preparing fetch: %s\n", sqlite.db_errmsg(d.db))
return EnvFile{}, false
}
defer sqlite.finalize(stmt)
cpath := to_cstring(path, allocator)
defer delete(cpath, allocator)
rc = sqlite.bind_text(stmt, 1, cpath, -1, nil)
if rc != sqlite.OK {
fmt.printf("Error binding path: %s\n", sqlite.db_errmsg(d.db))
return EnvFile{}, false
}
rc = sqlite.step(stmt)
if rc == sqlite.DONE {
fmt.printf("No file found with path: %s\n", path)
return EnvFile{}, false
}
if rc != sqlite.ROW {
fmt.printf("Error fetching: %s\n", sqlite.db_errmsg(d.db))
return EnvFile{}, false
}
remotes_json := string(sqlite.column_text(stmt, 1))
remotes: [dynamic]string = ---
if len(remotes_json) > 0 {
json.unmarshal_string(remotes_json, &remotes, allocator = allocator)
}
file_path := clone_cstring(sqlite.column_text(stmt, 0))
return EnvFile {
Path = file_path,
Dir = filepath.dir(file_path),
Remotes = remotes,
Sha256 = clone_cstring(sqlite.column_text(stmt, 2), allocator),
contents = clone_cstring(sqlite.column_text(stmt, 3), allocator),
},
true
}
db_delete :: proc(d: ^Db, path: string) -> bool {
sql: cstring = "DELETE FROM envr_env_files WHERE path = ?"
stmt: ^rawptr
rc := sqlite.prepare_v2(d.db, sql, -1, &stmt, nil)
if rc != sqlite.OK {
fmt.printf("Error preparing delete: %s\n", sqlite.db_errmsg(d.db))
return false
}
defer sqlite.finalize(stmt)
cpath := to_cstring(path)
defer delete(cpath)
rc = sqlite.bind_text(stmt, 1, cpath, -1, nil)
if rc != sqlite.OK {
fmt.printf("Error binding path: %s\n", sqlite.db_errmsg(d.db))
return false
}
rc = sqlite.step(stmt)
if rc != sqlite.DONE {
fmt.printf("Error deleting: %s\n", sqlite.db_errmsg(d.db))
return false
}
if sqlite.changes(d.db) == 0 {
fmt.printf("No file found with path: %s\n", path)
return false
}
d.changed = true
return true
}
new_env_file :: proc(path: string) -> (EnvFile, bool) {
abs_path, abs_err := filepath.abs(path)
if abs_err != nil {
fmt.printf("Error getting absolute path: %v\n", abs_err)
return EnvFile{}, false
}
dir := filepath.dir(abs_path)
remotes := get_git_remotes(dir)
data, read_err := os.read_entire_file_from_path(abs_path, context.allocator)
defer delete(data)
if read_err != nil {
fmt.printf("Error reading file %s: %v\n", abs_path, read_err)
return EnvFile{}, false
}
digest := hash.hash_bytes(hash.Algorithm.SHA256, data, context.temp_allocator)
// TODO: Handle error
hex_bytes, _ := hex.encode(digest)
return EnvFile {
Path = abs_path,
Dir = dir,
Remotes = remotes,
Sha256 = string(hex_bytes),
contents = string(data),
},
true
}
db_sync :: proc(d: ^Db, f: ^EnvFile) -> (SyncFlag, string) {
return env_file_sync(f, .TrustFilesystem, d)
}
// If SyncFlag is .BackedUp, Caller is responsible for calling delete on f.contents and f.Sha256
env_file_sync :: proc(f: ^EnvFile, dir: SyncDirection, d: ^Db) -> (SyncFlag, string) {
result: SyncFlag = {}
_, stat_err := os.stat(f.Dir, context.allocator)
if stat_err != nil {
moved_dirs: [dynamic]string
if d != nil {
dirs, dirs_ok := find_moved_dirs(d, f)
if !dirs_ok {
return {.Error}, "failed to find moved dirs"
}
moved_dirs = dirs
}
if len(moved_dirs) == 0 {
return {.Error}, "directory missing"
} else if len(moved_dirs) == 1 {
update_dir(f, moved_dirs[0])
result = {.DirUpdated}
} else {
return {.Error}, "multiple directories found"
}
}
_, file_stat_err := os.stat(f.Path, context.allocator)
if file_stat_err != nil {
write_err := os.write_entire_file(f.Path, f.contents)
if write_err != nil {
msg, _ := strings.concatenate({"failed to write file: ", fmt.tprintf("%v", write_err)})
return {.Error}, msg
}
return result + {.Restored}, ""
}
data, read_err := os.read_entire_file_from_path(f.Path, context.allocator)
if read_err != nil {
msg, _ := strings.concatenate(
{"failed to read file for SHA comparison: ", fmt.tprintf("%v", read_err)},
)
return {.Error}, msg
}
digest := hash.hash_bytes(hash.Algorithm.SHA256, data)
// TODO: Handle error
hex_bytes, _ := hex.encode(digest)
current_sha := string(hex_bytes)
if current_sha == f.Sha256 {
return result, ""
}
switch dir {
case .TrustDatabase:
write_err := os.write_entire_file(f.Path, f.contents)
if write_err != nil {
msg, _ := strings.concatenate({"failed to write file: ", fmt.tprintf("%v", write_err)})
return {.Error}, msg
}
return result + {.Restored}, ""
case .TrustFilesystem:
if !env_file_backup(f) {
return {.Error}, "failed to backup file"
}
return result + {.BackedUp}, ""
}
return result, ""
}
find_moved_dirs :: proc(d: ^Db, f: ^EnvFile) -> ([dynamic]string, bool) {
roots, roots_ok := find_git_roots(d.cfg)
if !roots_ok {
return {}, false
}
moved: [dynamic]string
for root in roots {
remotes := get_git_remotes(root)
if shares_remote(f, remotes[:]) {
cloned, _ := strings.clone(root)
append(&moved, cloned)
}
}
return moved, true
}
update_dir :: proc(f: ^EnvFile, new_dir: string) {
f.Dir = new_dir
base := filepath.base(f.Path)
new_path, _ := strings.concatenate({new_dir, "/", base})
f.Path = new_path
f.Remotes = get_git_remotes(new_dir)
}
// Loads the contents of the the file at f.Path into f.contents
//
// Caller is responsible for calling delete on f.contents and f.Sha256
env_file_backup :: proc(f: ^EnvFile) -> bool {
data, read_err := os.read_entire_file_from_path(f.Path, context.allocator)
if read_err != nil {
fmt.printf("Error reading file %s: %v\n", f.Path, read_err)
return false
}
f.contents = string(data)
digest := hash.hash_bytes(hash.Algorithm.SHA256, data, context.temp_allocator)
hex_bytes, alloc_err := hex.encode(digest)
if alloc_err != nil {
fmt.printf("Error generating hash for file %s: %v\n", f.Path, alloc_err)
return false
}
f.Sha256 = string(hex_bytes)
return true
}
shares_remote :: proc(f: ^EnvFile, remotes: []string) -> bool {
for r1 in f.Remotes {
for r2 in remotes {
if r1 == r2 {
return true
}
}
}
return false
}
get_git_remotes :: proc(dir: string) -> [dynamic]string {
remotes: [dynamic]string
remote_set: map[string]bool
defer delete(remote_set)
config_path, _ := filepath.join({dir, ".git", "config"}, context.temp_allocator)
m, _, ok := ini.load_map_from_path(config_path, context.allocator)
if !ok {
return remotes
}
defer ini.delete_map(m)
for section_name, section in m {
if strings.has_prefix(section_name, "remote ") {
if url, ok := section["url"]; ok {
remote_set[url] = true
}
}
}
for remote in remote_set {
cloned, _ := strings.clone(remote)
append(&remotes, cloned)
}
return remotes
}
db_update_required :: proc(status: SyncFlag) -> bool {
return .BackedUp in status || .DirUpdated in status
}
to_cstring :: proc {
string_to_cstring,
strings.to_cstring,
}
string_to_cstring :: proc(s: string, allocator := context.allocator) -> cstring {
cs, err := strings.clone_to_cstring(s, allocator)
if err != nil {
fmt.printf("Failed to convert string to cstring: %v\n", err)
panic("Allocation Exception")
}
return cs
}
// Caller is responsible for freeing the result
clone_cstring :: proc(c: cstring, allocator := context.allocator) -> string {
str, err := strings.clone_from_cstring(c, allocator)
if err != nil {
fmt.printf("Failed to convert string to cstring: %v\n", err)
delete(str)
panic("Allocation Exception")
}
return str
}

337
db_integration_test.odin Normal file
View File

@@ -0,0 +1,337 @@
package main
import "core:fmt"
import "core:os"
import "core:path/filepath"
import "core:strings"
import "core:testing"
import "sqlite"
FIXTURES :: "fixtures"
fixture_key :: proc() -> SshKeyPair {
priv, _ := strings.concatenate(
[]string{FIXTURES, "/keys/insecure-test-key"},
context.temp_allocator,
)
pub, _ := strings.concatenate(
[]string{FIXTURES, "/keys/insecure-test-key.pub"},
context.temp_allocator,
)
return SshKeyPair{Private = priv, Public = pub}
}
fixture_db_path :: proc() -> string {
p, _ := strings.concatenate([]string{FIXTURES, "/single-file.db"}, context.temp_allocator)
return p
}
fixture_config :: proc() -> Config {
cfg := Config {
Keys = make([dynamic]SshKeyPair, 0, 1),
}
append(&cfg.Keys, fixture_key())
return cfg
}
@(test)
test_encrypt_decrypt_sqlite_roundtrip :: proc(t: ^testing.T) {
cfg := fixture_config()
defer {
delete(cfg.Keys)
}
db_path := fixture_db_path()
sqlite_data, read_err := os.read_entire_file_from_path(db_path, context.allocator)
testing.expectf(t, read_err == nil, "failed to read fixture db: %v", read_err)
if read_err != nil {
return
}
defer delete(sqlite_data)
encrypted, enc_ok := encrypt(sqlite_data, cfg.Keys[:])
testing.expect(t, enc_ok, "encryption should succeed")
if !enc_ok {
return
}
defer delete(encrypted)
testing.expect(t, len(encrypted) >= HEADER_SIZE, "ciphertext should have header")
testing.expect(t, encrypted[0] == u8('E'), "magic byte 0")
testing.expect(t, encrypted[1] == u8('N'), "magic byte 1")
testing.expect(t, encrypted[2] == u8('V'), "magic byte 2")
testing.expect(t, encrypted[3] == u8('R'), "magic byte 3")
plaintext, dec_ok := decrypt(encrypted, cfg.Keys[:])
testing.expect(t, dec_ok, "decryption should succeed")
if !dec_ok {
return
}
defer delete(plaintext)
testing.expectf(
t,
len(plaintext) == len(sqlite_data),
"round-trip size mismatch: expected %d, got %d",
len(sqlite_data),
len(plaintext),
)
match := true
for i in 0 ..< len(sqlite_data) {
if plaintext[i] != sqlite_data[i] {
match = false
break
}
}
testing.expect(t, match, "decrypted data should match original")
}
@(test)
test_encrypt_write_read_decrypt :: proc(t: ^testing.T) {
cfg := fixture_config()
defer {
delete(cfg.Keys)
}
db_path := fixture_db_path()
sqlite_data, read_err := os.read_entire_file_from_path(db_path, context.allocator)
testing.expectf(t, read_err == nil, "failed to read fixture db: %v", read_err)
if read_err != nil {
return
}
defer delete(sqlite_data)
encrypted, enc_ok := encrypt(sqlite_data, cfg.Keys[:])
testing.expect(t, enc_ok, "encryption should succeed")
if !enc_ok {
return
}
defer delete(encrypted)
tmp_enc_path := fmt.tprintf("/tmp/envr-test-ewrd-%d.envr", os.get_pid())
write_err := os.write_entire_file(tmp_enc_path, encrypted)
testing.expectf(t, write_err == nil, "failed to write encrypted file: %v", write_err)
if write_err != nil {
return
}
defer os.remove(tmp_enc_path)
read_back, rb_err := os.read_entire_file_from_path(tmp_enc_path, context.allocator)
testing.expectf(t, rb_err == nil, "failed to read back encrypted file: %v", rb_err)
if rb_err != nil {
return
}
defer delete(read_back)
plaintext, dec_ok := decrypt(read_back, cfg.Keys[:])
testing.expect(t, dec_ok, "decryption after write/read should succeed")
if !dec_ok {
return
}
defer delete(plaintext)
testing.expect(t, len(plaintext) == len(sqlite_data), "size mismatch after file round-trip")
}
@(test)
test_decrypt_then_deserialize_sqlite :: proc(t: ^testing.T) {
cfg := fixture_config()
defer {
delete(cfg.Keys)
}
db_path := fixture_db_path()
sqlite_data, read_err := os.read_entire_file_from_path(db_path, context.allocator)
testing.expectf(t, read_err == nil, "failed to read fixture db: %v", read_err)
if read_err != nil {
return
}
defer delete(sqlite_data)
encrypted, enc_ok := encrypt(sqlite_data, cfg.Keys[:])
testing.expect(t, enc_ok, "encryption should succeed")
if !enc_ok {
return
}
defer delete(encrypted)
plaintext, dec_ok := decrypt(encrypted, cfg.Keys[:])
testing.expect(t, dec_ok, "decryption should succeed")
if !dec_ok {
return
}
defer delete(plaintext)
mem_db: ^rawptr
rc := sqlite.db_open(":memory:", &mem_db)
testing.expectf(t, rc == sqlite.OK, "failed to open in-memory db")
if rc != sqlite.OK {
return
}
defer sqlite.db_close(mem_db)
n := i64(len(plaintext))
buf := sqlite.malloc64(n)
testing.expect(t, buf != nil, "malloc64 should succeed")
if buf == nil do return
copy(buf[:len(plaintext)], plaintext)
rc = sqlite.deserialize(
mem_db,
"main",
buf,
n,
n,
sqlite.DESERIALIZE_FREEONCLOSE | sqlite.DESERIALIZE_RESIZEABLE,
)
testing.expect(t, rc == sqlite.OK, "deserialize should succeed")
if rc != sqlite.OK {
sqlite.free(buf)
return
}
sql: cstring = "SELECT path FROM envr_env_files"
stmt: ^rawptr
rc = sqlite.prepare_v2(mem_db, sql, -1, &stmt, nil)
testing.expect(t, rc == sqlite.OK, "prepare failed")
if rc != sqlite.OK {
return
}
defer sqlite.finalize(stmt)
rc = sqlite.step(stmt)
testing.expect(t, rc == sqlite.ROW, "expected at least one row")
if rc == sqlite.ROW {
path := string(sqlite.column_text(stmt, 0))
testing.expect(t, len(path) > 0, "path should not be empty")
}
}
@(test)
test_full_db_cycle :: proc(t: ^testing.T) {
cfg := fixture_config()
defer delete(cfg.Keys)
db_path := fixture_db_path()
original_data, read_err := os.read_entire_file_from_path(db_path, context.allocator)
testing.expectf(t, read_err == nil, "failed to read fixture db: %v", read_err)
if read_err != nil {
return
}
defer delete(original_data)
encrypted, enc_ok := encrypt(original_data, cfg.Keys[:])
testing.expect(t, enc_ok, "first encryption should succeed")
if !enc_ok {
return
}
defer delete(encrypted)
envr_dir_path := fmt.tprintf("/tmp/envr-test-cycle-%d/.envr", os.get_pid())
os.mkdir_all(envr_dir_path)
data_path, _ := filepath.join([]string{envr_dir_path, "data.envr"})
defer delete(data_path)
write_err := os.write_entire_file(data_path, encrypted)
testing.expectf(t, write_err == nil, "failed to write data.envr: %v", write_err)
if write_err != nil {
return
}
read_back, rb_err := os.read_entire_file_from_path(data_path, context.allocator)
testing.expectf(t, rb_err == nil, "failed to read data.envr: %v", rb_err)
if rb_err != nil {
return
}
defer delete(read_back)
plaintext, dec_ok := decrypt(read_back, cfg.Keys[:])
testing.expect(t, dec_ok, "decryption should succeed")
if !dec_ok {
return
}
defer delete(plaintext)
encrypted2, enc2_ok := encrypt(plaintext, cfg.Keys[:])
testing.expect(t, enc2_ok, "re-encryption should succeed")
if !enc2_ok {
return
}
defer delete(encrypted2)
plaintext2, dec2_ok := decrypt(encrypted2, cfg.Keys[:])
testing.expect(t, dec2_ok, "second decryption should succeed")
if !dec2_ok {
return
}
defer delete(plaintext2)
testing.expect(
t,
len(plaintext2) == len(original_data),
fmt.tprintf(
"double round-trip size mismatch: expected %d, got %d",
len(original_data),
len(plaintext2),
),
)
os.remove(data_path)
os.remove(envr_dir_path)
home := filepath.dir(filepath.dir(envr_dir_path))
os.remove(home)
}
@(test)
test_ssh_key_parse_from_fixtures :: proc(t: ^testing.T) {
key := fixture_key()
priv_kp, priv_ok := parse_ssh_private_key(key.Private)
testing.expect(t, priv_ok, "should parse private key from fixtures")
if !priv_ok {
return
}
pub_key, pub_ok := parse_ssh_public_key(key.Public)
testing.expect(t, pub_ok, "should parse public key from fixtures")
if !pub_ok {
return
}
for i in 0 ..< 32 {
testing.expectf(t, priv_kp.Public[i] == pub_key[i], "public key mismatch at byte %d", i)
}
x25519_pairs, x_ok := ssh_to_x25519([]SshKeyPair{key})
testing.expect(t, x_ok, "ssh_to_x25519 should succeed")
if !x_ok {
return
}
defer delete(x25519_pairs)
testing.expect(t, len(x25519_pairs) == 1, "should have 1 x25519 keypair")
}
@(test)
test_config_load_with_fixture_key :: proc(t: ^testing.T) {
cfg := fixture_config()
defer {
delete(cfg.Keys)
}
testing.expect(t, len(cfg.Keys) == 1, "should have 1 key")
key := cfg.Keys[0]
testing.expectf(t, len(key.Private) > 0, "private key path should not be empty")
testing.expectf(t, len(key.Public) > 0, "public key path should not be empty")
_, priv_ok := parse_ssh_private_key(key.Private)
testing.expect(t, priv_ok, "should parse private key using config paths")
if !priv_ok {
fmt.printf(" private key path was: '%s'\n", key.Private)
}
}

522
db_test.odin Normal file
View File

@@ -0,0 +1,522 @@
package main
import "core:fmt"
import "core:os"
import "core:path/filepath"
import "core:strings"
import "core:testing"
import "sqlite"
make_test_db :: proc() -> (Db, bool) {
db: ^rawptr
rc := sqlite.db_open(":memory:", &db)
if rc != sqlite.OK {
return Db{}, false
}
create_sql: cstring = "CREATE TABLE IF NOT EXISTS envr_env_files (path TEXT PRIMARY KEY NOT NULL, remotes TEXT, sha256 TEXT NOT NULL, contents TEXT NOT NULL)"
rc = sqlite.db_exec(db, create_sql, nil, nil, nil)
if rc != sqlite.OK {
sqlite.db_close(db)
return Db{}, false
}
return Db{db = db}, true
}
make_test_env_file :: proc(path, sha, contents: string, remotes: []string = {}) -> EnvFile {
f := EnvFile {
Path = path,
Dir = "",
Sha256 = sha,
contents = contents,
Remotes = make([dynamic]string, 0, len(remotes)),
}
for r in remotes {
append(&f.Remotes, r)
}
return f
}
@(test)
test_db_insert_and_fetch :: proc(t: ^testing.T) {
d, ok := make_test_db()
testing.expect(t, ok, "failed to create test db")
if !ok do return
defer sqlite.db_close(d.db)
path := "/project/.env"
sha := "abc123"
contents := "SECRET=value"
f := make_test_env_file(path, sha, contents, []string{"git@github.com:user/repo.git"})
defer delete(f.Remotes)
testing.expect(t, db_insert(&d, f), "insert should succeed")
fetched, fetch_ok := db_fetch(&d, "/project/.env")
defer delete_envfile(&fetched)
testing.expect(t, fetch_ok, "fetch should succeed")
if !fetch_ok do return
testing.expect_value(t, fetched.Path, path)
testing.expect_value(t, fetched.Sha256, sha)
testing.expect_value(t, fetched.contents, contents)
testing.expect_value(t, len(fetched.Remotes), 1)
testing.expect_value(t, fetched.Remotes[0], "git@github.com:user/repo.git")
}
@(test)
test_db_fetch_missing :: proc(t: ^testing.T) {
d, ok := make_test_db()
testing.expect(t, ok, "failed to create test db")
if !ok do return
defer sqlite.db_close(d.db)
_, fetch_ok := db_fetch(&d, "/nonexistent/.env")
testing.expect(t, !fetch_ok, "fetch missing should return false")
}
@(test)
test_db_insert_or_replace :: proc(t: ^testing.T) {
d, ok := make_test_db()
testing.expect(t, ok, "failed to create test db")
if !ok do return
defer sqlite.db_close(d.db)
f1 := make_test_env_file("/project/.env", "sha1", "KEY=old")
defer delete(f1.Remotes)
testing.expect(t, db_insert(&d, f1), "first insert should succeed")
f2 := make_test_env_file("/project/.env", "sha2", "KEY=new")
defer delete(f2.Remotes)
testing.expect(t, db_insert(&d, f2), "second insert should succeed")
results, list_ok := db_list(&d)
testing.expect(t, list_ok, "list should succeed")
if !list_ok do return
defer delete(results)
for &result in results {
defer delete_envfile(&result)
}
testing.expect(t, len(results) == 1, "should have 1 row, not 2")
fetched, fetch_ok := db_fetch(&d, "/project/.env")
testing.expect(t, fetch_ok, "fetch should succeed")
if !fetch_ok do return
defer delete_envfile(&fetched)
testing.expect_value(t, fetched.contents, "KEY=new")
testing.expect_value(t, fetched.Sha256, "sha2")
}
@(test)
test_db_delete_existing :: proc(t: ^testing.T) {
d, ok := make_test_db()
testing.expect(t, ok, "failed to create test db")
if !ok do return
defer sqlite.db_close(d.db)
f := make_test_env_file("/project/.env", "sha", "KEY=val")
defer delete(f.Remotes)
db_insert(&d, f)
testing.expect(t, db_delete(&d, "/project/.env"), "delete should return true")
_, fetch_ok := db_fetch(&d, "/project/.env")
testing.expect(t, !fetch_ok, "row should be gone after delete")
}
@(test)
test_db_delete_missing :: proc(t: ^testing.T) {
d, ok := make_test_db()
testing.expect(t, ok, "failed to create test db")
if !ok do return
defer sqlite.db_close(d.db)
testing.expect(t, !db_delete(&d, "/nonexistent/.env"), "delete missing should return false")
}
@(test)
test_db_list_multiple :: proc(t: ^testing.T) {
d, ok := make_test_db()
testing.expect(t, ok, "failed to create test db")
if !ok do return
defer sqlite.db_close(d.db)
f1 := make_test_env_file("/proj1/.env", "sha1", "A=1", []string{"git@github.com:a/repo.git"})
defer delete(f1.Remotes)
f2 := make_test_env_file("/proj2/.env", "sha2", "B=2", []string{"git@github.com:b/repo.git"})
defer delete(f2.Remotes)
f3 := make_test_env_file("/proj3/.env", "sha3", "C=3")
db_insert(&d, f1)
db_insert(&d, f2)
db_insert(&d, f3)
results, list_ok := db_list(&d)
testing.expect(t, list_ok, "list should succeed")
if !list_ok do return
defer delete(results)
defer {
for &result in results {
delete_envfile(&result)
}
}
testing.expect_value(t, len(results), 3)
}
@(test)
test_db_list_empty :: proc(t: ^testing.T) {
d, ok := make_test_db()
testing.expect(t, ok, "failed to create test db")
if !ok do return
defer sqlite.db_close(d.db)
results, list_ok := db_list(&d)
testing.expect(t, list_ok, "list should succeed on empty db")
testing.expect(t, len(results) == 0, "should have 0 rows")
if list_ok do delete(results)
}
@(test)
test_db_insert_sets_changed :: proc(t: ^testing.T) {
d, ok := make_test_db()
testing.expect(t, ok, "failed to create test db")
if !ok do return
defer sqlite.db_close(d.db)
testing.expect(t, !d.changed, "changed should start false")
f := make_test_env_file("/project/.env", "sha", "KEY=val")
defer delete(f.Remotes)
db_insert(&d, f)
testing.expect(t, d.changed, "changed should be true after insert")
}
@(test)
test_db_delete_sets_changed :: proc(t: ^testing.T) {
d, ok := make_test_db()
testing.expect(t, ok, "failed to create test db")
if !ok do return
defer sqlite.db_close(d.db)
f := make_test_env_file("/project/.env", "sha", "KEY=val")
defer delete(f.Remotes)
db_insert(&d, f)
d.changed = false
db_delete(&d, "/project/.env")
testing.expect(t, d.changed, "changed should be true after delete")
}
@(test)
test_db_serialize :: proc(t: ^testing.T) {
d, ok := make_test_db()
testing.expect(t, ok, "failed to create test db")
if !ok do return
defer sqlite.db_close(d.db)
f := make_test_env_file("/project/.env", "sha", "KEY=val")
defer delete(f.Remotes)
db_insert(&d, f)
sz: i64
data := sqlite.serialize(d.db, "main", &sz, 0)
testing.expect(t, data != nil, "serialize should return non-nil")
if data == nil do return
defer sqlite.free(data)
testing.expect(t, sz > 0, "serialized size should be > 0")
}
@(test)
test_db_update_required_noop :: proc(t: ^testing.T) {
testing.expect(t, !db_update_required({}), "Noop should not require update")
}
@(test)
test_db_update_required_backed_up :: proc(t: ^testing.T) {
testing.expect(t, db_update_required({.BackedUp}), "BackedUp should require update")
}
@(test)
test_db_update_required_dir_updated :: proc(t: ^testing.T) {
testing.expect(t, db_update_required({.DirUpdated}), "DirUpdated should require update")
}
@(test)
test_db_update_required_restored :: proc(t: ^testing.T) {
testing.expect(t, !db_update_required({.Restored}), "Restored alone should not require update")
}
@(test)
test_db_update_required_error :: proc(t: ^testing.T) {
testing.expect(t, !db_update_required({.Error}), "Error alone should not require update")
}
@(test)
test_db_update_required_combined :: proc(t: ^testing.T) {
combined := SyncFlag{.DirUpdated, .Restored}
testing.expect(t, db_update_required(combined), "DirUpdated|Restored should require update")
}
@(test)
test_shares_remote_overlap :: proc(t: ^testing.T) {
f := EnvFile {
Remotes = make([dynamic]string, 2, context.temp_allocator),
}
append(&f.Remotes, "git@github.com:user/repo.git")
append(&f.Remotes, "git@gitlab.com:user/repo.git")
remotes := []string{"git@github.com:user/repo.git"}
testing.expect(t, shares_remote(&f, remotes), "should share remote")
}
@(test)
test_shares_remote_no_overlap :: proc(t: ^testing.T) {
f := EnvFile {
Remotes = make([dynamic]string, 1, context.temp_allocator),
}
append(&f.Remotes, "git@github.com:user/repo.git")
remotes := []string{"git@github.com:other/repo.git"}
testing.expect(t, !shares_remote(&f, remotes), "should not share remote")
}
@(test)
test_shares_remote_empty_file_remotes :: proc(t: ^testing.T) {
f := EnvFile {
Remotes = make([dynamic]string, 0, context.temp_allocator),
}
remotes := []string{"git@github.com:user/repo.git"}
testing.expect(t, !shares_remote(&f, remotes), "empty file remotes should not share")
}
@(test)
test_shares_remote_empty_check_remotes :: proc(t: ^testing.T) {
f := EnvFile {
Remotes = make([dynamic]string, 1, context.temp_allocator),
}
append(&f.Remotes, "git@github.com:user/repo.git")
remotes: []string
testing.expect(t, !shares_remote(&f, remotes), "empty check remotes should not share")
}
@(test)
test_shares_remote_both_empty :: proc(t: ^testing.T) {
f := EnvFile {
Remotes = make([dynamic]string, 0),
}
remotes: []string
testing.expect(t, !shares_remote(&f, remotes), "both empty should not share")
}
delete_remotes :: proc(remotes: [dynamic]string) {
for &r in remotes {
delete(r)
}
delete(remotes)
}
@(test)
test_get_git_remotes_single :: proc(t: ^testing.T) {
base := fmt.tprintf("/tmp/envr-test-remotes-%d", os.get_pid())
os.mkdir_all(base)
defer os.remove_all(base)
git_dir := fmt.tprintf("%s/.git", base)
os.mkdir_all(git_dir)
config_content := "[core]\n\trepositoryformatversion = 0\n[remote \"origin\"]\n\turl = git@github.com:user/repo.git\n\tfetch = +refs/heads/*:refs/remotes/origin/*\n"
config_path := fmt.tprintf("%s/config", git_dir)
err := os.write_entire_file(config_path, transmute([]u8)config_content)
testing.expect(t, err == nil, "should write .git/config")
remotes := get_git_remotes(base)
defer delete_remotes(remotes)
testing.expect(t, len(remotes) == 1, "should find 1 remote")
if len(remotes) != 1 do return
testing.expect_value(t, remotes[0], "git@github.com:user/repo.git")
}
@(test)
test_get_git_remotes_multiple :: proc(t: ^testing.T) {
base := fmt.tprintf("/tmp/envr-test-remotes-multi-%d", os.get_pid())
os.mkdir_all(base)
defer os.remove_all(base)
git_dir := fmt.tprintf("%s/.git", base)
os.mkdir_all(git_dir)
config_content := "[remote \"origin\"]\n\turl = git@github.com:user/repo.git\n[remote \"upstream\"]\n\turl = https://gitlab.com/upstream/repo.git\n"
config_path := fmt.tprintf("%s/config", git_dir)
err := os.write_entire_file(config_path, transmute([]u8)config_content)
testing.expect(t, err == nil, "should write .git/config")
remotes := get_git_remotes(base)
defer delete_remotes(remotes)
testing.expect(t, len(remotes) == 2, "should find 2 remotes")
}
@(test)
test_get_git_remotes_no_config :: proc(t: ^testing.T) {
base := fmt.tprintf("/tmp/envr-test-remotes-none-%d", os.get_pid())
os.mkdir_all(base)
defer os.remove_all(base)
remotes := get_git_remotes(base)
defer delete_remotes(remotes)
testing.expect(t, len(remotes) == 0, "should return empty when no .git/config")
}
@(test)
test_get_git_remotes_no_remotes :: proc(t: ^testing.T) {
base := fmt.tprintf("/tmp/envr-test-remotes-empty-%d", os.get_pid())
os.mkdir_all(base)
defer os.remove_all(base)
git_dir := fmt.tprintf("%s/.git", base)
os.mkdir_all(git_dir)
config_content := "[core]\n\trepositoryformatversion = 0\n\tbare = false\n"
config_path := fmt.tprintf("%s/config", git_dir)
err := os.write_entire_file(config_path, transmute([]u8)config_content)
testing.expect(t, err == nil, "should write .git/config")
remotes := get_git_remotes(base)
defer delete_remotes(remotes)
testing.expect(t, len(remotes) == 0, "should return empty when no remote sections")
}
@(test)
test_new_env_file :: proc(t: ^testing.T) {
base := fmt.tprintf("/tmp/envr-test-envfile-%d", os.get_pid())
os.mkdir_all(base)
defer os.remove_all(base)
env_path := fmt.tprintf("%s/.env", base)
err := os.write_entire_file(env_path, "SECRET=value\n")
testing.expect(t, err == nil, ".env file should exists")
file, ok := new_env_file(env_path)
testing.expect(t, ok, "new_env_file should succeed")
if !ok do return
defer delete(file.Remotes)
defer delete(file.Sha256)
defer delete(file.Path)
testing.expect(t, filepath.is_abs(file.Path), "path should be absolute")
testing.expect(t, strings.has_suffix(file.Path, "/.env"), "path should end with /.env")
testing.expect(t, file.contents == "SECRET=value\n", "contents mismatch")
testing.expect(t, len(file.Sha256) == 64, "sha256 should be 64 hex chars")
}
@(test)
test_new_env_file_missing :: proc(t: ^testing.T) {
_, ok := new_env_file("/tmp/envr-nonexistent-envfile/path/.env")
testing.expect(t, !ok, "missing file should return false")
}
@(test)
test_env_file_backup :: proc(t: ^testing.T) {
base := fmt.tprintf("/tmp/envr-test-backup-%d", os.get_pid())
os.mkdir_all(base)
defer os.remove_all(base)
env_path := fmt.tprintf("%s/.env", base)
err := os.write_entire_file(env_path, "KEY=12345\n")
testing.expect(t, err == nil, ".env file should exist")
f := EnvFile {
Path = env_path,
}
defer delete(f.contents)
defer delete(f.Sha256)
testing.expect(t, env_file_backup(&f), "backup should succeed")
testing.expect_value(t, f.contents, "KEY=12345\n")
testing.expect_value(t, len(f.Sha256), 64)
}
@(test)
test_env_file_backup_missing :: proc(t: ^testing.T) {
f := EnvFile {
Path = "/tmp/envr-nonexistent-backup/.env",
}
testing.expect(t, !env_file_backup(&f), "missing file should return false")
}
@(test)
test_update_dir :: proc(t: ^testing.T) {
f := EnvFile {
Path = "/old/project/.env",
Dir = "/old/project",
Remotes = make([dynamic]string, 0),
}
defer delete_envfile(&f)
update_dir(&f, "/new/location")
testing.expect_value(t, f.Dir, "/new/location")
testing.expect_value(t, f.Path, "/new/location/.env")
}
@(test)
test_closing_db_has_no_leaks :: proc(t: ^testing.T) {
base := fmt.tprintf("/tmp/envr-test-leak-%d", os.get_pid())
os.mkdir_all(base)
defer os.remove_all(base)
cfg_path, err := filepath.join([]string{base, "config.json"}, context.temp_allocator)
testing.expect(t, err == nil, "cfgPath should build successfully")
cfg := new_config([]string{"fixtures/keys/insecure-test-key"}, cfg_path)
defer delete_config(&cfg)
testing.expect(t, save_config(cfg, force = true), "save should succeed")
db, ok := db_open(cfg_path)
testing.expect(t, ok, "db should open")
if !ok do return
db_close(&db)
}
@(test)
test_open_existing_db_has_no_leaks :: proc(t: ^testing.T) {
base := fmt.tprintf("/tmp/envr-test-leak-existing-%d", os.get_pid())
os.mkdir_all(base)
defer os.remove_all(base)
cfg_path, err := filepath.join([]string{base, "config.json"}, context.temp_allocator)
testing.expect(t, err == nil, "cfgPath should build successfully")
cfg := new_config([]string{"fixtures/keys/insecure-test-key"}, cfg_path)
defer delete_config(&cfg)
testing.expect(t, save_config(cfg, force = true), "save should succeed")
// First open/close creates data.envr on disk
db, ok := db_open(cfg_path)
testing.expect(t, ok, "db should open")
if !ok do return
f := make_test_env_file("/project/.env", "abc123", "SECRET=value", []string{"git@github.com:user/repo.git"})
defer delete(f.Remotes)
testing.expect(t, db_insert(&db, f), "insert should succeed")
db_close(&db)
// Second open exercises db_restore_from_encrypted
db2, ok2 := db_open(cfg_path)
testing.expect(t, ok2, "db should open existing")
if !ok2 do return
db_close(&db2)
}

57
docs/cli/envr.md Normal file
View File

@@ -0,0 +1,57 @@
## envr
Manage your .env files.
### Synopsis
envr keeps your .env synced to a local, age encrypted database.
Is a safe and eay way to gather all your .env files in one place where they can
easily be backed by another tool such as restic or git.
All your data is stored in ~/data.age
Getting started is easy:
1. Create your configuration file and set up encrypted storage:
> envr init
2. Scan for existing .env files:
> envr scan
Select the files you want to back up from the interactive list.
3. Verify that it worked:
> envr list
4. After changing any of your .env files, update the backup with:
> envr sync
5. If you lose a repository, after re-cloning the repo into the same path it was
at before, restore your backup with:
> envr restore ~/&lt;path to repository&gt;/.env
### Options
```
-h, --help help for envr
```
### SEE ALSO
* [envr backup](envr_backup.md) - Import a .env file into envr
* [envr check](envr_check.md) - check if files in the current directory are backed up
* [envr edit-config](envr_edit-config.md) - Edit your config with your default editor
* [envr init](envr_init.md) - Set up envr
* [envr list](envr_list.md) - View your tracked files
* [envr nushell-completion](envr_nushell-completion.md) - Generate custom completions for nushell
* [envr remove](envr_remove.md) - Remove a .env file from your database
* [envr restore](envr_restore.md) - Install a .env file from the database into your file system
* [envr scan](envr_scan.md) - Find and select .env files for backup
* [envr sync](envr_sync.md) - Update or restore your env backups
* [envr version](envr_version.md) - Show envr's version

18
docs/cli/envr_backup.md Normal file
View File

@@ -0,0 +1,18 @@
## envr backup
Import a .env file into envr
```
envr backup <path> [flags]
```
### Options
```
-h, --help help for backup
```
### SEE ALSO
* [envr](envr.md) - Manage your .env files.

18
docs/cli/envr_check.md Normal file
View File

@@ -0,0 +1,18 @@
## envr check
check if files in the current directory are backed up
```
envr check [path] [flags]
```
### Options
```
-h, --help help for check
```
### SEE ALSO
* [envr](envr.md) - Manage your .env files.

View File

@@ -0,0 +1,18 @@
## envr edit-config
Edit your config with your default editor
```
envr edit-config [flags]
```
### Options
```
-h, --help help for edit-config
```
### SEE ALSO
* [envr](envr.md) - Manage your .env files.

28
docs/cli/envr_init.md Normal file
View File

@@ -0,0 +1,28 @@
## envr init
Set up envr
### Synopsis
The init command generates your initial config and saves it to
~/.envr/config in JSON format.
During setup, you will be prompted to select one or more ssh keys with which to
encrypt your databse. **Make 100% sure** that you have **a remote copy** of this
key somewhere, otherwise your data could be lost forever.
```
envr init [flags]
```
### Options
```
-f, --force Overwrite an existing config
-h, --help help for init
```
### SEE ALSO
* [envr](envr.md) - Manage your .env files.

18
docs/cli/envr_list.md Normal file
View File

@@ -0,0 +1,18 @@
## envr list
View your tracked files
```
envr list [flags]
```
### Options
```
-h, --help help for list
```
### SEE ALSO
* [envr](envr.md) - Manage your .env files.

View File

@@ -0,0 +1,23 @@
## envr nushell-completion
Generate custom completions for nushell
### Synopsis
At time of writing, cobra does not natively support nushell,
so a custom command had to be written
```
envr nushell-completion [flags]
```
### Options
```
-h, --help help for nushell-completion
```
### SEE ALSO
* [envr](envr.md) - Manage your .env files.

18
docs/cli/envr_remove.md Normal file
View File

@@ -0,0 +1,18 @@
## envr remove
Remove a .env file from your database
```
envr remove [flags]
```
### Options
```
-h, --help help for remove
```
### SEE ALSO
* [envr](envr.md) - Manage your .env files.

18
docs/cli/envr_restore.md Normal file
View File

@@ -0,0 +1,18 @@
## envr restore
Install a .env file from the database into your file system
```
envr restore [flags]
```
### Options
```
-h, --help help for restore
```
### SEE ALSO
* [envr](envr.md) - Manage your .env files.

18
docs/cli/envr_scan.md Normal file
View File

@@ -0,0 +1,18 @@
## envr scan
Find and select .env files for backup
```
envr scan [flags]
```
### Options
```
-h, --help help for scan
```
### SEE ALSO
* [envr](envr.md) - Manage your .env files.

18
docs/cli/envr_sync.md Normal file
View File

@@ -0,0 +1,18 @@
## envr sync
Update or restore your env backups
```
envr sync [flags]
```
### Options
```
-h, --help help for sync
```
### SEE ALSO
* [envr](envr.md) - Manage your .env files.

19
docs/cli/envr_version.md Normal file
View File

@@ -0,0 +1,19 @@
## envr version
Show envr's version
```
envr version [flags]
```
### Options
```
-h, --help help for version
-l, --long Show all version information
```
### SEE ALSO
* [envr](envr.md) - Manage your .env files.

320
findr/findr_test.odin Normal file
View File

@@ -0,0 +1,320 @@
package findr
import "core:os"
import "core:sort"
import "core:strings"
import "core:sys/linux"
import "core:testing"
// ============================================================================
// Gitignored file emission tests (emit ONLY gitignored files, descend everywhere)
// ============================================================================
@(test)
test_basic_gitignored :: proc(t: ^testing.T) {
env := create_test_env()
defer destroy_test_env(&env)
create_git_repo(env, "repo")
create_file(env, "repo/.gitignore", "*.env\n")
create_file(env, "repo/.env")
create_file(env, "repo/secrets.env")
create_file(env, "repo/normal.txt")
assert_output(t, env, nil, {}, {
"repo/.env", "repo/secrets.env",
})
}
@(test)
test_non_repo_not_scanned :: proc(t: ^testing.T) {
env := create_test_env()
defer destroy_test_env(&env)
create_dir(env, "norepo")
create_file(env, "norepo/.gitignore", "*.env\n")
create_file(env, "norepo/.env")
assert_output_empty(t, env, nil, {})
}
@(test)
test_negation_pattern :: proc(t: ^testing.T) {
env := create_test_env()
defer destroy_test_env(&env)
create_git_repo(env, "repo")
create_file(env, "repo/.gitignore", "*.env\n!prod.env\n")
create_file(env, "repo/.env")
create_file(env, "repo/secrets.env")
create_file(env, "repo/prod.env")
assert_output(t, env, nil, {}, {
"repo/.env", "repo/secrets.env",
})
}
@(test)
test_multiple_repos :: proc(t: ^testing.T) {
env := create_test_env()
defer destroy_test_env(&env)
create_git_repo(env, "repo1")
create_file(env, "repo1/.gitignore", "*.env\n")
create_file(env, "repo1/a.env")
create_git_repo(env, "repo2")
create_file(env, "repo2/.gitignore", "*.key\n")
create_file(env, "repo2/secret.key")
assert_output(t, env, nil, {}, {
"repo1/a.env", "repo2/secret.key",
})
}
@(test)
test_nested_repos :: proc(t: ^testing.T) {
env := create_test_env()
defer destroy_test_env(&env)
create_git_repo(env, "parent")
create_file(env, "parent/.gitignore", "*.env\n")
create_file(env, "parent/top.env")
create_git_repo(env, "parent/child")
create_file(env, "parent/child/.gitignore", "*.key\n")
create_file(env, "parent/child/api.key")
assert_output(t, env, nil, {}, {
"parent/top.env", "parent/child/api.key",
})
}
@(test)
test_nested_gitignore_read :: proc(t: ^testing.T) {
env := create_test_env()
defer destroy_test_env(&env)
create_git_repo(env, "repo")
create_file(env, "repo/.gitignore", "*.env\n")
create_dir(env, "repo/sub")
create_file(env, "repo/sub/.gitignore", "*.txt\n")
create_file(env, "repo/sub/secret.txt")
create_file(env, "repo/sub/.env")
assert_output(t, env, nil, {}, {
"repo/sub/secret.txt", "repo/sub/.env",
})
}
@(test)
test_nested_gitignore_negation :: proc(t: ^testing.T) {
env := create_test_env()
defer destroy_test_env(&env)
create_git_repo(env, "repo")
create_file(env, "repo/.gitignore", "*.log\n")
create_dir(env, "repo/sub")
create_file(env, "repo/sub/.gitignore", "!important.log\n")
create_file(env, "repo/sub/important.log")
create_file(env, "repo/sub/debug.log")
assert_output(t, env, nil, {}, {
"repo/sub/debug.log",
})
}
@(test)
test_multisegment_pattern :: proc(t: ^testing.T) {
env := create_test_env()
defer destroy_test_env(&env)
create_git_repo(env, "repo")
create_file(env, "repo/.gitignore", "build/output.txt\n")
create_dir(env, "repo/build")
create_file(env, "repo/build/output.txt")
create_file(env, "repo/build/other.txt")
create_file(env, "repo/output.txt")
assert_output(t, env, nil, {}, {
"repo/build/output.txt",
})
}
@(test)
test_no_gitignore_file :: proc(t: ^testing.T) {
env := create_test_env()
defer destroy_test_env(&env)
create_git_repo(env, "repo")
create_file(env, "repo/.env")
assert_output_empty(t, env, nil, {})
}
@(test)
test_empty_gitignore :: proc(t: ^testing.T) {
env := create_test_env()
defer destroy_test_env(&env)
create_git_repo(env, "repo")
create_file(env, "repo/.gitignore", "\n\n# comment\n\n")
create_file(env, "repo/.env")
assert_output_empty(t, env, nil, {})
}
@(test)
test_multiple_search_dirs :: proc(t: ^testing.T) {
env := create_test_env()
defer destroy_test_env(&env)
create_git_repo(env, "dir1/repo")
create_file(env, "dir1/repo/.gitignore", "*.env\n")
create_file(env, "dir1/repo/a.env")
create_file(env, "dir1/repo/normal.txt")
create_git_repo(env, "dir2/repo")
create_file(env, "dir2/repo/.gitignore", "*.env\n")
create_file(env, "dir2/repo/b.env")
dir1 := join_path(env.temp_dir, "dir1")
defer delete(dir1)
dir2 := join_path(env.temp_dir, "dir2")
defer delete(dir2)
results := make([dynamic]string)
defer {
for r in results {delete(r)}
delete(results)
}
opts := WalkOptions{}
thread_count := os.get_processor_core_count()
walk({dir1, dir2}, &results, opts, thread_count)
testing.expect_value(t, len(results), 2)
actual := make([dynamic]string, 0, len(results))
for r in results {
stripped := r
if strings.has_prefix(stripped, env.temp_dir) {
stripped = stripped[len(env.temp_dir):]
if len(stripped) > 0 && stripped[0] == '/' {
stripped = stripped[1:]
}
}
append(&actual, stripped)
}
defer delete(actual)
expected := []string{"dir1/repo/a.env", "dir2/repo/b.env"}
sort.quick_sort(actual[:])
sort.quick_sort(expected[:])
for i in 0 ..< len(expected) {
testing.expect_value(t, actual[i], expected[i])
}
}
// ============================================================================
// Ignored directory recursion tests
// ============================================================================
@(test)
test_ignored_dir_descended :: proc(t: ^testing.T) {
env := create_test_env()
defer destroy_test_env(&env)
create_git_repo(env, "repo")
create_file(env, "repo/.gitignore", "secrets/\n")
create_dir(env, "repo/secrets")
create_file(env, "repo/secrets/.env")
create_file(env, "repo/secrets/api.key")
// Ignored dir's contents are emitted AND descended into
assert_output(t, env, nil, {}, {
"repo/secrets/", "repo/secrets/.env", "repo/secrets/api.key",
})
}
@(test)
test_nested_ignored_dir :: proc(t: ^testing.T) {
env := create_test_env()
defer destroy_test_env(&env)
create_git_repo(env, "repo")
create_file(env, "repo/.gitignore", "build/\n")
create_dir(env, "repo/build")
create_dir(env, "repo/build/sub")
create_file(env, "repo/build/output.txt")
create_file(env, "repo/build/sub/deep.env")
assert_output(t, env, nil, {}, {
"repo/build/", "repo/build/output.txt",
"repo/build/sub/", "repo/build/sub/deep.env",
})
}
// ============================================================================
// Filter tests (excludes, pattern)
// ============================================================================
@(test)
test_excludes_prune_dirs :: proc(t: ^testing.T) {
env := create_test_env()
defer destroy_test_env(&env)
create_git_repo(env, "repo")
create_file(env, "repo/.gitignore", "*.env\n")
create_file(env, "repo/.env")
create_dir(env, "repo/vendor")
create_file(env, "repo/vendor/lib.env")
assert_output(t, env, nil,
{excludes = {"vendor"}},
{"repo/.env"},
)
}
@(test)
test_pattern_filters_results :: proc(t: ^testing.T) {
env := create_test_env()
defer destroy_test_env(&env)
create_git_repo(env, "repo")
create_file(env, "repo/.gitignore", "*.env\n*.key\n")
create_file(env, "repo/.env")
create_file(env, "repo/secrets.env")
create_file(env, "repo/master.key")
assert_output(t, env, nil,
{pattern = "\\.env$"},
{"repo/.env", "repo/secrets.env"},
)
}
// ============================================================================
// Special file type tests
// ============================================================================
@(test)
test_fifo_emitted :: proc(t: ^testing.T) {
env := create_test_env()
defer destroy_test_env(&env)
create_git_repo(env, "repo")
create_file(env, "repo/.gitignore", "*.env\n*.fifo\n")
fifo_path := join_path(env.temp_dir, "repo/test.fifo")
defer delete(fifo_path)
cpath := strings.clone_to_cstring(fifo_path)
defer delete(cpath)
linux.mknod(cpath, linux.S_IFIFO | linux.Mode{.IRUSR, .IWUSR}, 0)
assert_output(t, env, nil,
{pattern = "\\.fifo$"},
{"repo/test.fifo"},
)
}

88
findr/gitignore.odin Normal file
View File

@@ -0,0 +1,88 @@
package findr
import "core:strings"
Gitignore :: struct {
rules: [dynamic]Rule,
}
Rule :: struct {
pattern: GlobPattern,
negated: bool,
dir_only: bool,
}
Match :: enum {
None,
Ignored,
Unignored,
}
is_ignored :: proc(gi: ^Gitignore, path: string, is_dir: bool) -> bool {
return check_match(gi, path, is_dir) == .Ignored
}
check_match :: proc(gi: ^Gitignore, path: string, is_dir: bool) -> Match {
result := Match.None
for &rule in gi.rules {
if rule.dir_only && !is_dir do continue
if glob_match_compiled(&rule.pattern, path) {
result = rule.negated ? .Unignored : .Ignored
}
}
return result
}
parse :: proc(content: string) -> Gitignore {
gi: Gitignore
gi.rules = make([dynamic]Rule)
remaining := content
for {
line, ok := strings.split_lines_iterator(&remaining)
if !ok do break
s := strings.trim_space(line)
if len(s) == 0 do continue
if s[0] == '#' do continue
negated := false
if s[0] == '!' {
negated = true
s = s[1:]
}
if len(s) > 0 && s[0] == '\\' {
if len(s) > 1 && (s[1] == '#' || s[1] == '!') {
s = s[1:]
}
}
dir_only := false
if len(s) > 0 && s[len(s) - 1] == '/' {
dir_only = true
s = s[:len(s) - 1]
}
anchored := false
if len(s) > 0 && s[0] == '/' {
anchored = true
s = s[1:]
}
if len(s) == 0 do continue
gp := glob_compile(s, anchored)
append(&gi.rules, Rule{pattern = gp, negated = negated, dir_only = dir_only})
}
return gi
}
destroy :: proc(gi: ^Gitignore) {
for &rule in gi.rules {
glob_destroy(&rule.pattern)
}
delete(gi.rules)
}

118
findr/gitignore_test.odin Normal file
View File

@@ -0,0 +1,118 @@
package findr
import "core:testing"
@(test)
test_is_ignored_basic :: proc(t: ^testing.T) {
gi := parse("*.env\n")
defer destroy(&gi)
testing.expect_value(t, is_ignored(&gi, ".env", false), true)
testing.expect_value(t, is_ignored(&gi, "foo.env", false), true)
testing.expect_value(t, is_ignored(&gi, ".env.local", false), false)
testing.expect_value(t, is_ignored(&gi, "config.yaml", false), false)
}
@(test)
test_is_ignored_negation :: proc(t: ^testing.T) {
gi := parse("*.env\n!.env.production\n")
defer destroy(&gi)
testing.expect_value(t, is_ignored(&gi, ".env", false), true)
testing.expect_value(t, is_ignored(&gi, ".env.production", false), false)
}
@(test)
test_is_ignored_dir_only :: proc(t: ^testing.T) {
gi := parse("node_modules/\n")
defer destroy(&gi)
testing.expect_value(t, is_ignored(&gi, "node_modules", true), true)
testing.expect_value(t, is_ignored(&gi, "node_modules", false), false)
}
@(test)
test_is_ignored_anchored :: proc(t: ^testing.T) {
gi := parse("/secret.key\n")
defer destroy(&gi)
testing.expect_value(t, is_ignored(&gi, "secret.key", false), true)
}
@(test)
test_is_ignored_comments_skipped :: proc(t: ^testing.T) {
gi := parse("# this is a comment\n#another\n*.tmp\n")
defer destroy(&gi)
testing.expect_value(t, len(gi.rules), 1)
testing.expect_value(t, is_ignored(&gi, "file.tmp", false), true)
}
@(test)
test_is_ignored_blank_lines_skipped :: proc(t: ^testing.T) {
gi := parse("\n\n \n*.log\n\n")
defer destroy(&gi)
testing.expect_value(t, len(gi.rules), 1)
}
@(test)
test_is_ignored_last_match_wins :: proc(t: ^testing.T) {
gi := parse("*.env\n!*.env\n")
defer destroy(&gi)
testing.expect_value(t, is_ignored(&gi, ".env", false), false)
}
@(test)
test_is_ignored_no_rules :: proc(t: ^testing.T) {
gi := parse("")
defer destroy(&gi)
testing.expect_value(t, is_ignored(&gi, "anything", false), false)
}
@(test)
test_is_ignored_env_pattern :: proc(t: ^testing.T) {
gi := parse(".env*\n")
defer destroy(&gi)
testing.expect_value(t, is_ignored(&gi, ".env", false), true)
testing.expect_value(t, is_ignored(&gi, ".env.local", false), true)
testing.expect_value(t, is_ignored(&gi, ".envrc", false), true)
}
@(test)
test_is_ignored_globstar :: proc(t: ^testing.T) {
gi := parse("**/cache\n")
defer destroy(&gi)
testing.expect_value(t, is_ignored(&gi, "cache", false), true)
testing.expect_value(t, is_ignored(&gi, "foo/cache", false), true)
testing.expect_value(t, is_ignored(&gi, "foo/bar/cache", false), true)
}
@(test)
test_star_negation_subpath :: proc(t: ^testing.T) {
gi := parse("*\n!public/\n")
defer destroy(&gi)
// public dir itself is un-ignored
testing.expect_value(t, is_ignored(&gi, "public", true), false)
// children of public/ should still be ignored by *
testing.expect_value(t, is_ignored(&gi, "public/uuid-dir", true), true)
testing.expect_value(t, is_ignored(&gi, "public/uuid-dir/file.txt", false), true)
}
@(test)
test_is_ignored_hash_pattern :: proc(t: ^testing.T) {
gi := parse("\\#*\\#\n")
defer destroy(&gi)
testing.expect_value(t, is_ignored(&gi, "#foo#", false), true)
testing.expect_value(t, is_ignored(&gi, "#test#", false), true)
testing.expect_value(t, is_ignored(&gi, "AUTHORS", false), false)
testing.expect_value(t, is_ignored(&gi, "build.zig", false), false)
testing.expect_value(t, is_ignored(&gi, "ChangeLog", false), false)
}

203
findr/glob.odin Normal file
View File

@@ -0,0 +1,203 @@
package findr
Range :: struct {
lo: u8,
hi: u8,
}
Class_Data :: struct {
negated: bool,
ranges: [dynamic]Range,
}
Token_Kind :: enum u8 { Char, Star, Globstar, Question, Class }
Token :: struct {
kind: Token_Kind,
byte: u8,
class_idx: u16,
}
GlobPattern :: struct {
tokens: [dynamic]Token,
classes: [dynamic]Class_Data,
anchored: bool,
}
glob_compile :: proc(pattern: string, anchored: bool) -> GlobPattern {
gp: GlobPattern
gp.tokens = make([dynamic]Token)
gp.classes = make([dynamic]Class_Data)
gp.anchored = anchored
i := 0
for i < len(pattern) {
c := pattern[i]
if c == '*' {
if i + 1 < len(pattern) && pattern[i + 1] == '*' {
prev_slash := i == 0 || pattern[i - 1] == '/'
at_end := i + 2 >= len(pattern)
next_slash := !at_end && pattern[i + 2] == '/'
if prev_slash && (next_slash || at_end) {
append(&gp.tokens, Token{kind = .Globstar})
if next_slash {
i += 3
} else {
i += 2
}
} else {
append(&gp.tokens, Token{kind = .Star})
i += 2
}
} else {
append(&gp.tokens, Token{kind = .Star})
i += 1
}
} else if c == '?' {
append(&gp.tokens, Token{kind = .Question})
i += 1
} else if c == '[' {
i += 1
negated := false
if i < len(pattern) && pattern[i] == '!' {
negated = true
i += 1
}
ranges := make([dynamic]Range)
if i < len(pattern) && pattern[i] == ']' {
append(&ranges, Range{lo = ']', hi = ']'})
i += 1
}
for i < len(pattern) && pattern[i] != ']' {
if i + 2 < len(pattern) && pattern[i + 1] == '-' && pattern[i + 2] != ']' {
append(&ranges, Range{lo = pattern[i], hi = pattern[i + 2]})
i += 3
} else {
append(&ranges, Range{lo = pattern[i], hi = pattern[i]})
i += 1
}
}
if i < len(pattern) {
i += 1
}
class_idx := u16(len(gp.classes))
append(&gp.classes, Class_Data{negated = negated, ranges = ranges})
append(&gp.tokens, Token{kind = .Class, class_idx = class_idx})
} else if c == '\\' {
i += 1
if i < len(pattern) {
append(&gp.tokens, Token{kind = .Char, byte = pattern[i]})
i += 1
}
} else {
append(&gp.tokens, Token{kind = .Char, byte = c})
i += 1
}
}
return gp
}
match_tokens :: proc(tokens: []Token, classes: []Class_Data, ti: int, path: string, pi: int) -> bool {
if ti >= len(tokens) {
return pi == len(path)
}
tok := tokens[ti]
switch tok.kind {
case .Char:
if pi < len(path) && path[pi] == tok.byte {
return match_tokens(tokens, classes, ti + 1, path, pi + 1)
}
return false
case .Question:
if pi < len(path) && path[pi] != '/' {
return match_tokens(tokens, classes, ti + 1, path, pi + 1)
}
return false
case .Star:
max_end := pi
for max_end < len(path) && path[max_end] != '/' {
max_end += 1
}
for end := max_end; end >= pi; end -= 1 {
if match_tokens(tokens, classes, ti + 1, path, end) {
return true
}
}
return false
case .Globstar:
if ti + 1 >= len(tokens) {
return true
}
if match_tokens(tokens, classes, ti + 1, path, pi) {
return true
}
for end := pi + 1; end <= len(path); end += 1 {
if path[end - 1] == '/' {
if match_tokens(tokens, classes, ti + 1, path, end) {
return true
}
}
}
return false
case .Class:
if pi >= len(path) {
return false
}
cd := classes[tok.class_idx]
ch := path[pi]
in_range := false
for r in cd.ranges {
if ch >= r.lo && ch <= r.hi {
in_range = true
break
}
}
if in_range != cd.negated {
return match_tokens(tokens, classes, ti + 1, path, pi + 1)
}
return false
}
return false
}
glob_match_compiled :: proc(gp: ^GlobPattern, path: string) -> bool {
tokens := gp.tokens[:]
classes := gp.classes[:]
if gp.anchored {
return match_tokens(tokens, classes, 0, path, 0)
}
if match_tokens(tokens, classes, 0, path, 0) {
return true
}
for i := 1; i < len(path); i += 1 {
if path[i - 1] == '/' {
if match_tokens(tokens, classes, 0, path, i) {
return true
}
}
}
return false
}
glob_destroy :: proc(gp: ^GlobPattern) {
for &cd in gp.classes {
delete(cd.ranges)
}
delete(gp.classes)
delete(gp.tokens)
}

111
findr/glob_test.odin Normal file
View File

@@ -0,0 +1,111 @@
package findr
import "core:testing"
glob_match :: proc(pattern: string, path: string, anchored: bool) -> bool {
gp := glob_compile(pattern, anchored)
result := glob_match_compiled(&gp, path)
glob_destroy(&gp)
return result
}
@(test)
test_glob_simple :: proc(t: ^testing.T) {
testing.expect(t, glob_match("foo", "foo", false))
testing.expect(t, glob_match("foo", "bar/foo", false))
testing.expect(t, !glob_match("foo", "foobar", false))
testing.expect(t, !glob_match("foo", "foo/bar", false))
}
@(test)
test_glob_anchored :: proc(t: ^testing.T) {
testing.expect(t, glob_match("foo", "foo", true))
testing.expect(t, !glob_match("foo", "bar/foo", true))
testing.expect(t, !glob_match("foo", "foobar", true))
}
@(test)
test_glob_star :: proc(t: ^testing.T) {
testing.expect(t, glob_match("*.log", "test.log", false))
testing.expect(t, glob_match("*.log", ".log", false))
testing.expect(t, !glob_match("*.log", "test.txt", false))
testing.expect(t, !glob_match("*.log", "dir/test", false))
}
@(test)
test_glob_question :: proc(t: ^testing.T) {
testing.expect(t, glob_match("?.log", "a.log", false))
testing.expect(t, !glob_match("?.log", "ab.log", false))
testing.expect(t, !glob_match("?.log", ".log", false))
}
@(test)
test_glob_char_class :: proc(t: ^testing.T) {
testing.expect(t, glob_match("[abc].log", "a.log", false))
testing.expect(t, glob_match("[abc].log", "b.log", false))
testing.expect(t, !glob_match("[abc].log", "d.log", false))
}
@(test)
test_glob_negated_class :: proc(t: ^testing.T) {
testing.expect(t, glob_match("[!abc].log", "d.log", false))
testing.expect(t, !glob_match("[!abc].log", "a.log", false))
}
@(test)
test_glob_dot_literal :: proc(t: ^testing.T) {
testing.expect(t, glob_match(".env", ".env", false))
testing.expect(t, glob_match(".env", "dir/.env", false))
testing.expect(t, !glob_match(".env", "env", false))
testing.expect(t, !glob_match(".env", "x.env", false))
}
@(test)
test_glob_globstar_prefix :: proc(t: ^testing.T) {
testing.expect(t, glob_match("**/foo", "foo", false))
testing.expect(t, glob_match("**/foo", "a/b/foo", false))
testing.expect(t, !glob_match("**/foo", "foobar", false))
testing.expect(t, !glob_match("**/foo", "a/foobar", false))
}
@(test)
test_glob_globstar_suffix :: proc(t: ^testing.T) {
testing.expect(t, glob_match("abc/**", "abc/x", false))
testing.expect(t, glob_match("abc/**", "abc/x/y", false))
testing.expect(t, !glob_match("abc/**", "abc", false))
testing.expect(t, !glob_match("abc/**", "abcd/x", false))
}
@(test)
test_glob_globstar_middle :: proc(t: ^testing.T) {
testing.expect(t, glob_match("foo/**/bar", "foo/bar", false))
testing.expect(t, glob_match("foo/**/bar", "foo/x/bar", false))
testing.expect(t, !glob_match("foo/**/bar", "foo/barx", false))
testing.expect(t, !glob_match("foo/**/bar", "foo/x/y/baz", false))
}
@(test)
test_glob_backslash_escape :: proc(t: ^testing.T) {
testing.expect(t, glob_match("\\!foo", "!foo", false))
testing.expect(t, !glob_match("\\!foo", "foo", false))
}
@(test)
test_glob_hash_literal :: proc(t: ^testing.T) {
testing.expect(t, glob_match("#foo", "#foo", false))
testing.expect(t, !glob_match("#foo", "foo", false))
}
@(test)
test_glob_hash_pattern :: proc(t: ^testing.T) {
testing.expect(t, glob_match("#*#", "#test#", false))
testing.expect(t, glob_match("#*#", "##", false))
testing.expect(t, !glob_match("#*#", "test", false))
testing.expect(t, !glob_match("#*#", "#test", false))
}
@(test)
test_glob_empty :: proc(t: ^testing.T) {
testing.expect(t, glob_match("", "", false))
testing.expect(t, !glob_match("", "foo", false))
}

128
findr/repos.odin Normal file
View File

@@ -0,0 +1,128 @@
package findr
import "core:strings"
import "core:sync"
import "core:sys/linux"
import "core:thread"
RepoPool :: struct {
queue: [dynamic]string,
queue_mutex: sync.Mutex,
queue_sema: sync.Atomic_Sema,
results: ^[dynamic]string,
results_lock: sync.Mutex,
active: i64,
done: sync.One_Shot_Event,
threads: []^thread.Thread,
}
find_repos :: proc(roots: []string, results: ^[dynamic]string, thread_count: int) {
if len(roots) == 0 do return
pool := new(RepoPool)
pool.queue = make([dynamic]string)
pool.results = results
pool.active = i64(len(roots))
pool.threads = make([]^thread.Thread, thread_count)
for root in roots {
root_clone, _ := strings.clone(root)
append(&pool.queue, root_clone)
sync.atomic_sema_post(&pool.queue_sema)
}
for i in 0 ..< thread_count {
t := thread.create(repo_worker)
t.data = rawptr(pool)
t.init_context = context
thread.start(t)
pool.threads[i] = t
}
sync.one_shot_event_wait(&pool.done)
for _ in 0 ..< thread_count {
sync.atomic_sema_post(&pool.queue_sema)
}
for t in pool.threads {
thread.destroy(t)
}
delete(pool.threads)
for path in pool.queue {
delete(path)
}
delete(pool.queue)
free(pool)
}
repo_worker :: proc(t: ^thread.Thread) {
pool := cast(^RepoPool)t.data
for {
sync.atomic_sema_wait(&pool.queue_sema)
sync.mutex_lock(&pool.queue_mutex)
if len(pool.queue) == 0 {
sync.mutex_unlock(&pool.queue_mutex)
if sync.atomic_load_explicit(&pool.active, .Acquire) == 0 {
sync.one_shot_event_signal(&pool.done)
}
break
}
last := len(pool.queue) - 1
dir_path := pool.queue[last]
ordered_remove(&pool.queue, last)
sync.mutex_unlock(&pool.queue_mutex)
process_repo_dir(pool, dir_path)
delete(dir_path)
old := sync.atomic_sub_explicit(&pool.active, 1, .Release)
if old == 1 {
sync.one_shot_event_signal(&pool.done)
}
}
}
process_repo_dir :: proc(pool: ^RepoPool, dir_path: string) {
cpath := strings.clone_to_cstring(dir_path)
if cpath == nil do return
defer delete(cpath)
fd, open_err := linux.open(cpath, {.DIRECTORY, .CLOEXEC})
if open_err != .NONE do return
defer linux.close(fd)
if has_git_dir(fd) {
cloned, _ := strings.clone(dir_path)
sync.mutex_lock(&pool.results_lock)
append(pool.results, cloned)
sync.mutex_unlock(&pool.results_lock)
}
buf: [32 * 1024]u8
for {
n, errno := linux.getdents(fd, buf[:])
if n <= 0 || errno != .NONE do break
offs := 0
for d in linux.dirent_iterate_buf(buf[:n], &offs) {
name := linux.dirent_name(d)
if name == "." || name == ".." do continue
if name == ".git" do continue
if d.type == .DIR {
child_path := join_path(dir_path, name)
sync.atomic_add_explicit(&pool.active, 1, .Relaxed)
sync.mutex_lock(&pool.queue_mutex)
append(&pool.queue, child_path)
sync.mutex_unlock(&pool.queue_mutex)
sync.atomic_sema_post(&pool.queue_sema)
}
}
}
}

152
findr/test_env.odin Normal file
View File

@@ -0,0 +1,152 @@
package findr
import "core:fmt"
import "core:log"
import "core:os"
import "core:sort"
import "core:strings"
import "core:testing"
TestEnv :: struct {
temp_dir: string,
}
create_test_env :: proc() -> (env: TestEnv) {
tmp, err := os.mkdir_temp("", "findr-test-*", context.allocator)
if err != nil {
log.error("Failed to create temp dir:", err)
panic("Failed to create temp dir")
}
env.temp_dir = tmp
return
}
destroy_test_env :: proc(env: ^TestEnv) {
os.remove_all(env.temp_dir)
delete(env.temp_dir)
}
create_dir :: proc(env: TestEnv, path: string) {
full := join_path(env.temp_dir, path)
defer delete(full)
os.mkdir_all(full, os.Permissions_Default_Directory)
}
create_file :: proc(env: TestEnv, path: string, content: string = "") {
full := join_path(env.temp_dir, path)
defer delete(full)
dir_end := strings.last_index(full, "/")
if dir_end >= 0 {
dir_path := full[:dir_end]
os.mkdir_all(dir_path, os.Permissions_Default_Directory)
}
f, err := os.create(full)
if err != nil {
log.error("Failed to create file:", full, err)
return
}
if len(content) > 0 {
os.write_string(f, content)
}
os.close(f)
}
create_git_repo :: proc(env: TestEnv, path: string) {
sub := join_path(path, ".git")
defer delete(sub)
create_dir(env, sub)
}
assert_output :: proc(
t: ^testing.T,
env: TestEnv,
args: []string,
opts: WalkOptions,
expected: []string,
) {
results := collect_results(env, args, opts)
defer {
for r in results {delete(r)}
delete(results)
}
sorted_expected := make([dynamic]string, 0, len(expected))
for e in expected {append(&sorted_expected, e)}
defer delete(sorted_expected)
sorted_actual := make([dynamic]string, 0, len(results))
for a in results {append(&sorted_actual, a)}
defer delete(sorted_actual)
sort.quick_sort(sorted_expected[:])
sort.quick_sort(sorted_actual[:])
if len(sorted_expected) != len(sorted_actual) {
testing.fail(t)
log.error(
fmt.tprintf("Expected %d results, got %d", len(sorted_expected), len(sorted_actual)),
)
log.error("Expected:", sorted_expected[:])
log.error("Actual: ", sorted_actual[:])
return
}
for i in 0 ..< len(sorted_expected) {
if sorted_expected[i] != sorted_actual[i] {
testing.fail(t)
log.error(fmt.tprintf("Mismatch at index %d", i))
log.error("Expected:", sorted_expected[:])
log.error("Actual: ", sorted_actual[:])
return
}
}
}
assert_output_empty :: proc(
t: ^testing.T,
env: TestEnv,
args: []string,
opts: WalkOptions,
) {
results := collect_results(env, args, opts)
defer {
for r in results {delete(r)}
delete(results)
}
if len(results) > 0 {
testing.fail(t)
log.error(fmt.tprintf("Expected no results, got %d:", len(results)))
for r in results {
log.error(" ", r)
}
}
}
collect_results :: proc(env: TestEnv, args: []string, opts: WalkOptions) -> [dynamic]string {
results := make([dynamic]string)
full_args := make([dynamic]string, 0, len(args) + 1, context.temp_allocator)
append(&full_args, env.temp_dir)
for a in args {append(&full_args, a)}
thread_count := os.get_processor_core_count()
walk(full_args[:], &results, opts, thread_count)
for i in 0 ..< len(results) {
r := results[i]
if strings.has_prefix(r, env.temp_dir) {
stripped := r[len(env.temp_dir):]
if len(stripped) > 0 && stripped[0] == '/' {
stripped = stripped[1:]
}
new_r, _ := strings.clone(stripped)
delete(r)
results[i] = new_r
}
}
return results
}

458
findr/walker.odin Normal file
View File

@@ -0,0 +1,458 @@
package findr
import "core:bytes"
import "core:fmt"
import "core:os"
import "core:strings"
import "core:sync"
import "core:sync/chan"
import "core:sys/linux"
import "core:text/regex"
import "core:thread"
OUTPUT_BUF_SIZE :: 64 * 1024
WalkOptions :: struct {
pattern: string, // regex on basename; "" = match all
excludes: []string, // glob patterns to skip entirely
}
GIContext :: struct {
gi: ^Gitignore, // nil if this dir had no .gitignore
base_rel: string, // relative path from repo root to this dir
parent: ^GIContext, // parent context (nil if repo root)
}
WorkItem :: struct {
path: string, // absolute directory path
rel: string, // relative path from repo root ("" = root)
gi_ctx: ^GIContext, // gitignore chain (nil = outside any repo)
in_repo: bool, // true if inside a git repo
in_ignored: bool, // true if inside a gitignored directory
}
WalkerPool :: struct {
queue: [dynamic]WorkItem,
queue_mutex: sync.Mutex,
queue_sema: sync.Atomic_Sema,
result_chan: chan.Chan([]u8),
active: i64,
done: sync.One_Shot_Event,
threads: []^thread.Thread,
opts: WalkOptions,
pattern_re: regex.Regular_Expression,
has_pattern: bool,
exclude_gi: ^Gitignore,
all_contexts: [dynamic]^GIContext,
contexts_lock: sync.Mutex,
}
Collector_Data :: struct {
ch: chan.Chan([]u8),
results: ^[dynamic]string,
}
collect_worker :: proc(t: ^thread.Thread) {
data := cast(^Collector_Data)t.data
for {
batch := chan.recv(data.ch) or_break
defer delete(batch)
start := 0
for {
remaining: []u8
#no_bounds_check {remaining = batch[start:]}
idx := bytes.index_byte(remaining, '\n')
if idx < 0 do break
i := start + idx
if i > start {
segment: []u8
#no_bounds_check {segment = batch[start:i]}
s, _ := strings.clone(string(segment))
append(data.results, s)
}
start = i + 1
}
}
}
walk :: proc(roots: []string, results: ^[dynamic]string, opts: WalkOptions, thread_count: int) {
if len(roots) == 0 do return
ch, _ := chan.create(chan.Chan([]u8), max(2 * thread_count, 2), context.allocator)
defer chan.destroy(ch)
data := new(Collector_Data)
data.ch = ch
data.results = results
defer free(data)
collector := thread.create(collect_worker)
collector.data = rawptr(data)
collector.init_context = context
thread.start(collector)
pool := new(WalkerPool)
pool.queue = make([dynamic]WorkItem)
pool.result_chan = ch
pool.active = i64(len(roots))
pool.threads = make([]^thread.Thread, thread_count)
pool.all_contexts = make([dynamic]^GIContext)
pool.opts = opts
pool.exclude_gi = nil
pool.has_pattern = false
if len(opts.pattern) > 0 {
re, err := regex.create(opts.pattern, {regex.Flag.No_Capture})
if err == nil {
pool.pattern_re = re
pool.has_pattern = true
}
}
if len(opts.excludes) > 0 {
sb: strings.Builder
strings.builder_init(&sb)
for ex in opts.excludes {
fmt.sbprintf(&sb, "%s\n", ex)
}
content := strings.to_string(sb)
pool.exclude_gi = new(Gitignore)
pool.exclude_gi^ = parse(content)
strings.builder_destroy(&sb)
}
for root in roots {
root_clone, _ := strings.clone(root)
append(&pool.queue, WorkItem{path = root_clone})
sync.atomic_sema_post(&pool.queue_sema)
}
for i in 0 ..< thread_count {
t := thread.create(walk_worker)
t.data = rawptr(pool)
t.init_context = context
thread.start(t)
pool.threads[i] = t
}
sync.one_shot_event_wait(&pool.done)
for _ in 0 ..< thread_count {
sync.atomic_sema_post(&pool.queue_sema)
}
for t in pool.threads {
thread.destroy(t)
}
delete(pool.threads)
for item in pool.queue {
delete(item.path)
if len(item.rel) > 0 {delete(item.rel)}
}
delete(pool.queue)
for ctx in pool.all_contexts {
if ctx.gi != nil {
destroy(ctx.gi)
free(ctx.gi)
}
if len(ctx.base_rel) > 0 {
delete(ctx.base_rel)
}
free(ctx)
}
delete(pool.all_contexts)
if pool.has_pattern {
regex.destroy(pool.pattern_re)
}
if pool.exclude_gi != nil {
destroy(pool.exclude_gi)
free(pool.exclude_gi)
}
free(pool)
chan.close(ch)
thread.join(collector)
thread.destroy(collector)
}
flush_buf :: proc(ch: chan.Chan([]u8), local: ^[dynamic]u8) {
if len(local) == 0 do return
batch := local[:]
local^ = make([dynamic]u8, 0, OUTPUT_BUF_SIZE)
chan.send(ch, batch)
}
append_path :: proc(buf: ^[dynamic]u8, parent, name: string, trailing_slash: bool) {
need_sep := len(parent) > 0 && parent[len(parent) - 1] != '/'
size := len(parent) + len(name) + 1
if need_sep do size += 1
if trailing_slash do size += 1
old_len := len(buf)
reserve(buf, old_len + size)
resize(buf, old_len + size)
pos := old_len
pos += copy(buf[pos:], parent)
if need_sep {buf[pos] = '/'; pos += 1}
pos += copy(buf[pos:], name)
if trailing_slash {buf[pos] = '/'; pos += 1}
buf[pos] = '\n'
}
walk_worker :: proc(t: ^thread.Thread) {
pool := cast(^WalkerPool)t.data
local_buf := make([dynamic]u8, 0, OUTPUT_BUF_SIZE)
defer {
if len(local_buf) > 0 {
flush_buf(pool.result_chan, &local_buf)
}
delete(local_buf)
}
for {
sync.atomic_sema_wait(&pool.queue_sema)
sync.mutex_lock(&pool.queue_mutex)
if len(pool.queue) == 0 {
sync.mutex_unlock(&pool.queue_mutex)
if sync.atomic_load_explicit(&pool.active, .Acquire) == 0 {
sync.one_shot_event_signal(&pool.done)
}
break
}
last := len(pool.queue) - 1
item := pool.queue[last]
ordered_remove(&pool.queue, last)
sync.mutex_unlock(&pool.queue_mutex)
process_dir(pool, item, &local_buf)
delete(item.path)
if len(item.rel) > 0 {delete(item.rel)}
if len(local_buf) >= OUTPUT_BUF_SIZE {
flush_buf(pool.result_chan, &local_buf)
}
old := sync.atomic_sub_explicit(&pool.active, 1, .Release)
if old == 1 {
sync.one_shot_event_signal(&pool.done)
}
}
}
process_dir :: proc(pool: ^WalkerPool, item: WorkItem, local_buf: ^[dynamic]u8) {
dir_path := item.path
cpath := strings.clone_to_cstring(dir_path)
if cpath == nil do return
defer delete(cpath)
fd, open_err := linux.open(cpath, {.DIRECTORY, .CLOEXEC})
if open_err != .NONE do return
defer linux.close(fd)
has_git := false
if !item.in_ignored {
has_git = has_git_dir(fd)
}
gi_ctx := item.gi_ctx
rel := item.rel
if has_git {
gi_ctx = nil
rel = ""
}
child_in_repo := has_git || item.in_repo
gi: ^Gitignore = nil
if !item.in_ignored {
gi = load_ignore_patterns(dir_path, child_in_repo)
}
if gi != nil {
new_ctx := new(GIContext)
new_ctx.gi = gi
if len(rel) > 0 {
new_ctx.base_rel, _ = strings.clone(rel)
}
new_ctx.parent = gi_ctx
sync.mutex_lock(&pool.contexts_lock)
append(&pool.all_contexts, new_ctx)
sync.mutex_unlock(&pool.contexts_lock)
gi_ctx = new_ctx
}
buf: [32 * 1024]u8
rel_buf: [4096]u8
for {
n, errno := linux.getdents(fd, buf[:])
if n <= 0 || errno != .NONE do break
offs := 0
for d in linux.dirent_iterate_buf(buf[:n], &offs) {
name := linux.dirent_name(d)
if name == "." || name == ".." do continue
if name == ".git" do continue
is_dir := d.type == .DIR
is_nondir := d.type != .DIR
if pool.exclude_gi != nil && is_ignored(pool.exclude_gi, name, is_dir) {
continue
}
entry_rel := build_rel(rel_buf[:], rel, name)
ignored := false
if item.in_ignored {
ignored = true
} else if gi_ctx != nil {
ignored = check_chain(gi_ctx, entry_rel, is_dir)
}
if is_dir {
if ignored && matches_pattern(pool, name) {
append_path(local_buf, dir_path, name, true)
}
child_rel, _ := strings.clone(entry_rel)
child_path := join_path(dir_path, name)
push_work(
pool,
WorkItem {
path = child_path,
rel = child_rel,
gi_ctx = gi_ctx,
in_repo = child_in_repo,
in_ignored = ignored,
},
)
} else if is_nondir {
if ignored && matches_pattern(pool, name) {
append_path(local_buf, dir_path, name, false)
}
}
}
}
}
check_chain :: proc(ctx: ^GIContext, entry_rel: string, is_dir: bool) -> bool {
c := ctx
for c != nil {
if c.gi != nil {
rel := relative_to(entry_rel, c.base_rel)
match := check_match(c.gi, rel, is_dir)
if match != .None {
return match == .Ignored
}
}
c = c.parent
}
return false
}
relative_to :: proc(entry_rel, base_rel: string) -> string {
if len(base_rel) == 0 do return entry_rel
prefix_len := len(base_rel)
if len(entry_rel) > prefix_len &&
entry_rel[prefix_len] == '/' &&
strings.has_prefix(entry_rel, base_rel) {
return entry_rel[prefix_len + 1:]
}
return entry_rel
}
build_rel :: proc(buf: []u8, rel, name: string) -> string {
if len(rel) == 0 do return name
pos := copy(buf, rel)
if pos < len(buf) {
buf[pos] = '/'
pos += 1
pos += copy(buf[pos:], name)
}
return string(buf[:pos])
}
matches_pattern :: proc(pool: ^WalkerPool, name: string) -> bool {
if !pool.has_pattern do return true
cap, ok := regex.match(pool.pattern_re, name)
regex.destroy(cap)
return ok
}
push_work :: proc(pool: ^WalkerPool, item: WorkItem) {
sync.atomic_add_explicit(&pool.active, 1, .Relaxed)
sync.mutex_lock(&pool.queue_mutex)
append(&pool.queue, item)
sync.mutex_unlock(&pool.queue_mutex)
sync.atomic_sema_post(&pool.queue_sema)
}
has_git_dir :: proc(fd: linux.Fd) -> bool {
git_fd, err := linux.openat(fd, ".git", {.DIRECTORY, .CLOEXEC})
if err == .NONE {
linux.close(git_fd)
return true
}
return false
}
load_ignore_patterns :: proc(dir_path: string, in_repo: bool) -> ^Gitignore {
has_patterns := false
sb: strings.Builder
strings.builder_init(&sb)
defer strings.builder_destroy(&sb)
if in_repo {
gi_path := join_path(dir_path, ".gitignore")
data, err := os.read_entire_file_from_path(gi_path, context.allocator)
delete(gi_path)
if err == .NONE {
fmt.sbprintf(&sb, "%s", string(data))
delete(data)
has_patterns = true
}
}
ig_path := join_path(dir_path, ".ignore")
idata, ierr := os.read_entire_file_from_path(ig_path, context.allocator)
delete(ig_path)
if ierr == .NONE {
fmt.sbprintf(&sb, "%s", string(idata))
delete(idata)
has_patterns = true
}
if !has_patterns do return nil
content := strings.to_string(sb)
gi := new(Gitignore)
gi^ = parse(content)
return gi
}
join_path :: proc(parent, child: string) -> string {
need_sep := len(parent) == 0 || parent[len(parent) - 1] != '/'
total := len(parent) + len(child)
if need_sep do total += 1
buf := make([]u8, total, context.allocator)
pos := copy(buf, parent)
if need_sep {
buf[pos] = '/'
pos += 1
}
copy(buf[pos:], child)
return string(buf)
}

View File

@@ -0,0 +1,7 @@
-----BEGIN OPENSSH PRIVATE KEY-----
b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW
QyNTUxOQAAACCbll0MJper9prPwGn2wwikH3hTByL8tlzmhViuvfrryAAAAJCkxfzapMX8
2gAAAAtzc2gtZWQyNTUxOQAAACCbll0MJper9prPwGn2wwikH3hTByL8tlzmhViuvfrryA
AAAEDXQExhs89b3fjqJHkhuo9QX4JEjXiEC+vSnCAYc8OxcpuWXQwml6v2ms/AafbDCKQf
eFMHIvy2XOaFWK69+uvIAAAACnNwZW5jZXJAZncBAgM=
-----END OPENSSH PRIVATE KEY-----

View File

@@ -0,0 +1 @@
ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIJuWXQwml6v2ms/AafbDCKQfeFMHIvy2XOaFWK69+uvI spencer@fw

View File

@@ -0,0 +1,7 @@
-----BEGIN OPENSSH PRIVATE KEY-----
b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW
QyNTUxOQAAACC4CdhiPHmU44cyy9UZV1ISnDq9RbYl1m1qTYOXaSNougAAAIg+8A82PvAP
NgAAAAtzc2gtZWQyNTUxOQAAACC4CdhiPHmU44cyy9UZV1ISnDq9RbYl1m1qTYOXaSNoug
AAAEAalxEoCavixCImtND1I0YHZZjhOrBLxk//t9v0sjYNVLgJ2GI8eZTjhzLL1RlXUhKc
Or1FtiXWbWpNg5dpI2i6AAAABHRlc3QB
-----END OPENSSH PRIVATE KEY-----

View File

@@ -0,0 +1 @@
ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAILgJ2GI8eZTjhzLL1RlXUhKcOr1FtiXWbWpNg5dpI2i6 test

View File

@@ -0,0 +1,8 @@
-----BEGIN OPENSSH PRIVATE KEY-----
b3BlbnNzaC1rZXktdjEAAAAACmFlczI1Ni1jdHIAAAAGYmNyeXB0AAAAGAAAABD342Kol/
iE3kW3alqJTPVpAAAAGAAAAAEAAAAzAAAAC3NzaC1lZDI1NTE5AAAAIF29NuS3O0JUKCj4
j/NmmJJyJk6n/MwI37WtVeWAC5c/AAAAoPFp0zRQufp8S+f68atSqFT1FYMUvGqL2cmmtJ
r+kXEeEvSGdi3xAxCSLuoe0tMeUYP8aUP1M5L9VzTpFoi8jBIfcPl/ZRX8F/+J4dhp5jno
3nQuo1AN0D60r+UmmX+Z0IzIrD2jIpZ/Y7P2kXT8OErIhtC4ZJs3nIIOKFY7ZzlM1IqbYH
dSSlpUnsAoMPjMb0eD0Q6s6JaldfiNshckauU=
-----END OPENSSH PRIVATE KEY-----

View File

@@ -0,0 +1 @@
ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIF29NuS3O0JUKCj4j/NmmJJyJk6n/MwI37WtVeWAC5c/ encrypted test key

View File

@@ -0,0 +1,7 @@
-----BEGIN OPENSSH PRIVATE KEY-----
b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW
QyNTUxOQAAACCZhSOlxHj1zxd+P7adxHOjo3tqqe68AVQ1itJ96nJ95wAAAIh6gz6PeoM+
jwAAAAtzc2gtZWQyNTUxOQAAACCZhSOlxHj1zxd+P7adxHOjo3tqqe68AVQ1itJ96nJ95w
AAAEAEsVzs6egkWMZolD/pZCX5ZcZVXfd5wZ6Ja12f+PxAQJmFI6XEePXPF34/tp3Ec6Oj
e2qp7rwBVDWK0n3qcn3nAAAABXRlc3Qy
-----END OPENSSH PRIVATE KEY-----

View File

@@ -0,0 +1 @@
ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIJmFI6XEePXPF34/tp3Ec6Oje2qp7rwBVDWK0n3qcn3n test2

27
fixtures/keys/test_rsa Normal file
View File

@@ -0,0 +1,27 @@
-----BEGIN OPENSSH PRIVATE KEY-----
b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAABFwAAAAdzc2gtcn
NhAAAAAwEAAQAAAQEAjwq/ISeK/TmKiV1NABIq+tFwevArpTRTyZ9eC5JyGvDzDB03buVl
6bXd6+cwv+h0AZa7BZN60ayv8zAUmyGpSxFN2gMFiJ/0iFYpTHiLZD4VUH8mCPllIehOdr
epchmlh14BeShJjlGzwBAlgiEON5V62gCWWLmkIzcAgUd3R2NUQfajl74wA0JBkaNeFwUp
nUARyPUeMVX8ZVUvbpE/WOFTZYfFZDkul6aSkAzEeyZq9s4qJ2mWt5acuXcMcUl6YtuAGM
Xii+uV1nJyQpNgHRdEZ2Ch1zmtiTrqjutdBUOfyQZJ3Ln9h/nPJDerUHZboyhu654dLbac
0P3pYciW8wAAA8BvZFJ5b2RSeQAAAAdzc2gtcnNhAAABAQCPCr8hJ4r9OYqJXU0AEir60X
B68CulNFPJn14LknIa8PMMHTdu5WXptd3r5zC/6HQBlrsFk3rRrK/zMBSbIalLEU3aAwWI
n/SIVilMeItkPhVQfyYI+WUh6E52t6lyGaWHXgF5KEmOUbPAECWCIQ43lXraAJZYuaQjNw
CBR3dHY1RB9qOXvjADQkGRo14XBSmdQBHI9R4xVfxlVS9ukT9Y4VNlh8VkOS6XppKQDMR7
Jmr2zionaZa3lpy5dwxxSXpi24AYxeKL65XWcnJCk2AdF0RnYKHXOa2JOuqO610FQ5/JBk
ncuf2H+c8kN6tQdlujKG7rnh0ttpzQ/elhyJbzAAAAAwEAAQAAAQAVAR96x1s1/vaUYDJ3
4bMU/J83NkA6dJofH7tIGLuPsDUIYNvseVwDOxT42IyEiaZLO26ADZ1535FAtR05gHJjFw
nnCw2Ld+2I/Zn35DWXxTQNC3ay16hdl8a50RNdMV3oqEmwGFXgw6eQ+u3/E0qKp/UPwQlS
wwPStfdphGyD+15BxNcc/ZTAByKe9JMi7KkygE02jUn9OMPjJJT9RR+oRXZHLq+yU8Fayl
QUDgmU5Vq8Mhp0P4JrmCMVeZuRhMPrk3XaDJFPgfSMY1fKEapW6itwsG9VTh6xUMxks26t
hk/GuGNjhmt5NOKpQDLLOTKd22u+PZ6kJJQcJjsj47ktAAAAgGcWjHLNm6T0Dp1p5hgfPy
QK019Xp24V1zlejyC0iykzBaC+ZFFS9JOBkqfdrrEE1nAzLvJblhUeWpmLBaqOF+PpPxkF
oAGXzYck2axVcXhpvgB71uOARGZntVDoxVoOC7vT6I2h8eL75pZNGYJZt1K9Zufr4UwNR4
F+FY194pSLAAAAgQDEx1MSFuVZ5sfAH7RteSHWjvyD/CWwbhVzL3IWeUXCMsf9HwUZZd8e
zgyqE6Dh65GTXviuy8Tpb4gT4Gne/QblMHGvdbFMlXNOfzz9U5VkF0q1Y/D4rN0Sa7+nzR
lZx/LKM20egfypNeJWBQT5KzZ8gEOamL7Qyyk5YG2q5evWnwAAAIEAuhdRyPjXaCM2NyvO
dPxvbnpEJZDWRw6iVWtzPAXgwIiI6ngEUVXK2O8T8j0Ufssk3AVbVj1OH8/KJonyWUbedM
mDaFhs4Uvd9iuSZdpS7PbLqHYonurg3m6dz4TrtoWUQuBATdGuIGrtkN+Y83e6UqOGT7lY
Vqw7lPqhNUowAy0AAAAIdGVzdC1yc2EBAgM=
-----END OPENSSH PRIVATE KEY-----

View File

@@ -0,0 +1 @@
ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQCPCr8hJ4r9OYqJXU0AEir60XB68CulNFPJn14LknIa8PMMHTdu5WXptd3r5zC/6HQBlrsFk3rRrK/zMBSbIalLEU3aAwWIn/SIVilMeItkPhVQfyYI+WUh6E52t6lyGaWHXgF5KEmOUbPAECWCIQ43lXraAJZYuaQjNwCBR3dHY1RB9qOXvjADQkGRo14XBSmdQBHI9R4xVfxlVS9ukT9Y4VNlh8VkOS6XppKQDMR7Jmr2zionaZa3lpy5dwxxSXpi24AYxeKL65XWcnJCk2AdF0RnYKHXOa2JOuqO610FQ5/JBkncuf2H+c8kN6tQdlujKG7rnh0ttpzQ/elhyJbz test-rsa

BIN
fixtures/single-file.db Normal file

Binary file not shown.

53
flake.lock generated
View File

@@ -5,11 +5,11 @@
"nixpkgs-lib": "nixpkgs-lib" "nixpkgs-lib": "nixpkgs-lib"
}, },
"locked": { "locked": {
"lastModified": 1760948891, "lastModified": 1778716662,
"narHash": "sha256-TmWcdiUUaWk8J4lpjzu4gCGxWY6/Ok7mOK4fIFfBuU4=", "narHash": "sha256-m1Yf0wZ8j1OHjTc2UwHwyQRSnNeSgLJOd7q5Y45hzi4=",
"owner": "hercules-ci", "owner": "hercules-ci",
"repo": "flake-parts", "repo": "flake-parts",
"rev": "864599284fc7c0ba6357ed89ed5e2cd5040f0c04", "rev": "f7c1a2d347e4c52d5fb8d10cb4d94b5884e546fb",
"type": "github" "type": "github"
}, },
"original": { "original": {
@@ -20,25 +20,27 @@
}, },
"nixpkgs": { "nixpkgs": {
"locked": { "locked": {
"lastModified": 1761594641, "lastModified": 1767313136,
"narHash": "sha256-sImk6SJQASDLQo8l+0zWWaBgg7TueLS6lTvdH5pBZpo=", "narHash": "sha256-16KkgfdYqjaeRGBaYsNrhPRRENs0qzkQVUooNHtoy2w=",
"owner": "NixOS", "owner": "NixOS",
"repo": "nixpkgs", "repo": "nixpkgs",
"rev": "1666250dbe4141e4ca8aaf89b40a3a51c2e36144", "rev": "ac62194c3917d5f474c1a844b6fd6da2db95077d",
"type": "github" "type": "github"
}, },
"original": { "original": {
"id": "nixpkgs", "owner": "NixOS",
"type": "indirect" "ref": "nixos-25.05",
"repo": "nixpkgs",
"type": "github"
} }
}, },
"nixpkgs-lib": { "nixpkgs-lib": {
"locked": { "locked": {
"lastModified": 1754788789, "lastModified": 1777168982,
"narHash": "sha256-x2rJ+Ovzq0sCMpgfgGaaqgBSwY+LST+WbZ6TytnT9Rk=", "narHash": "sha256-GOkGPcboWE9BmGCRMLX3worL4EMnsnG8MyKmXNeYuhQ=",
"owner": "nix-community", "owner": "nix-community",
"repo": "nixpkgs.lib", "repo": "nixpkgs.lib",
"rev": "a73b9c743612e4244d865a2fdee11865283c04e6", "rev": "f5901329dade4a6ea039af1433fb087bd9c1fe14",
"type": "github" "type": "github"
}, },
"original": { "original": {
@@ -49,11 +51,11 @@
}, },
"nixpkgs-unstable": { "nixpkgs-unstable": {
"locked": { "locked": {
"lastModified": 1761594641, "lastModified": 1781173989,
"narHash": "sha256-sImk6SJQASDLQo8l+0zWWaBgg7TueLS6lTvdH5pBZpo=", "narHash": "sha256-fnzKKPvS+oieI/pTzotA5tkoM47EB1NpaBcgk4R97hE=",
"owner": "NixOS", "owner": "NixOS",
"repo": "nixpkgs", "repo": "nixpkgs",
"rev": "1666250dbe4141e4ca8aaf89b40a3a51c2e36144", "rev": "8c91a71d13451abc40eb9dae8910f972f979852f",
"type": "github" "type": "github"
}, },
"original": { "original": {
@@ -67,7 +69,28 @@
"inputs": { "inputs": {
"flake-parts": "flake-parts", "flake-parts": "flake-parts",
"nixpkgs": "nixpkgs", "nixpkgs": "nixpkgs",
"nixpkgs-unstable": "nixpkgs-unstable" "nixpkgs-unstable": "nixpkgs-unstable",
"treefmt-nix": "treefmt-nix"
}
},
"treefmt-nix": {
"inputs": {
"nixpkgs": [
"nixpkgs"
]
},
"locked": {
"lastModified": 1780220602,
"narHash": "sha256-eynAfOmbmxJnkp7YewvCEbShNnnYJ9gLLqkzsYtBPeM=",
"owner": "numtide",
"repo": "treefmt-nix",
"rev": "db947814a175b7ca6ded66e21383d938df01c227",
"type": "github"
},
"original": {
"owner": "numtide",
"repo": "treefmt-nix",
"type": "github"
} }
} }
}, },

View File

@@ -2,39 +2,112 @@
description = "Manage your .env files."; description = "Manage your .env files.";
inputs = { inputs = {
# nixpkgs.url = "github:NixOS/nixpkgs/nixos-25.05"; nixpkgs.url = "github:NixOS/nixpkgs/nixos-25.05";
nixpkgs-unstable.url = "github:NixOS/nixpkgs/nixpkgs-unstable"; nixpkgs-unstable.url = "github:NixOS/nixpkgs/nixpkgs-unstable";
flake-parts.url = "github:hercules-ci/flake-parts"; flake-parts.url = "github:hercules-ci/flake-parts";
treefmt-nix.url = "github:numtide/treefmt-nix";
treefmt-nix.inputs.nixpkgs.follows = "nixpkgs";
}; };
outputs = outputs =
inputs@{ self inputs@{
, flake-parts flake-parts,
, nixpkgs nixpkgs,
, nixpkgs-unstable nixpkgs-unstable,
self,
treefmt-nix,
}: }:
flake-parts.lib.mkFlake { inherit inputs; } { flake-parts.lib.mkFlake { inherit inputs; } {
systems = [ "x86_64-linux" ]; imports = [
inputs.treefmt-nix.flakeModule
];
systems = [
"x86_64-linux"
"aarch64-linux"
"aarch64-darwin"
];
perSystem = perSystem =
{ pkgs, system, inputs', ... }: { {
pkgs,
system,
inputs',
...
}:
let
mysqlite = pkgs.sqlite.overrideAttrs (old: {
configureFlags = (old.configureFlags or [ ]) ++ [ "--enable-deserialize" ];
});
in
{
_module.args.pkgs = import nixpkgs { _module.args.pkgs = import nixpkgs {
inherit system; inherit system;
config.allowUnfree = true; config.allowUnfree = true;
overlays = [ overlays = [
(final: prev: { unstable = inputs'.nixpkgs-unstable.legacyPackages; }) (_final: _prev: { unstable = inputs'.nixpkgs-unstable.legacyPackages; })
]; ];
}; };
devShells.default = pkgs.mkShell treefmt = {
{ projectRootFile = "flake.nix";
settings.global.excludes = [
".direnv/**"
".jj/**"
".env"
".envrc"
".env.local"
];
programs.nixpkgs-fmt.enable = true;
};
packages.default = pkgs.stdenv.mkDerivation rec {
pname = "envr";
version = "0.3.0";
src = ./.;
nativeBuildInputs = [
pkgs.unstable.odin
pkgs.pkg-config
];
buildInputs = [
pkgs.libsodium
mysqlite
];
buildPhase = ''
runHook preBuild
echo '${version}' > version.txt
odin build . -o:speed -out:${pname}
runHook postBuild
'';
installPhase = ''
runHook preInstall
install -Dm755 ${pname} $out/bin/${pname}
runHook postInstall
'';
};
devShells.default = pkgs.mkShell {
buildInputs = with pkgs; [ buildInputs = with pkgs; [
age
fd
nushell nushell
sqlite
libsodium
mysqlite
unstable.odin
unstable.ols
# Build tools
zip
# Helper tools
delta
hyperfine
# IDE # IDE
unstable.helix unstable.helix

62
main.odin Normal file
View File

@@ -0,0 +1,62 @@
package main
import "core:fmt"
import "core:mem"
import "core:os"
main :: proc() {
when ODIN_DEBUG {
heap_track: mem.Tracking_Allocator
mem.tracking_allocator_init(&heap_track, context.allocator)
defer mem.tracking_allocator_destroy(&heap_track)
defer if len(heap_track.allocation_map) > 0 {
for _, leak in heap_track.allocation_map {
fmt.eprintf("LEAK: %v leaked %m\n", leak.location, leak.size)
}
}
context.allocator = mem.tracking_allocator(&heap_track)
temp_track: mem.Tracking_Allocator
mem.tracking_allocator_init(&temp_track, context.temp_allocator)
defer mem.tracking_allocator_destroy(&temp_track)
context.temp_allocator = mem.tracking_allocator(&temp_track)
}
defer free_all(context.temp_allocator)
cmd, ok := parse_args(os.args, os.to_writer(os.stdout), os.to_writer(os.stderr))
defer delete_command(&cmd) // delete flushes automatically
if !ok {
return
}
switch cmd.name {
case "init":
cmd_init(&cmd)
case "version":
cmd_version(&cmd)
case "list":
cmd_list(&cmd)
case "backup", "add":
cmd_backup(&cmd)
case "remove":
cmd_remove(&cmd)
case "restore":
cmd_restore(&cmd)
case "edit-config":
cmd_edit_config(&cmd)
case "check":
cmd_check(&cmd)
case "scan":
cmd_scan(&cmd)
case "sync":
cmd_sync(&cmd)
case "nushell-completion":
cmd_nushell_completion(&cmd)
case:
fmt.wprintf(cmd.err, "Unknown command: %s\n", cmd.name)
write_usage(cmd.out)
os.exit(1)
}
}

354
mod.nu Executable file → Normal file
View File

@@ -1,317 +1,71 @@
#!/usr/bin/env nu # envr command extern definitions for Nushell
# # A tool for managing environment files and backups
# TODO: Wrap blocks that use tmp files in try so we can clean them up.
use std assert; export def tracked-paths [] {
# Manage your .env files with ease
@example "Set up envr" { envr init }
export def envr [] {
help envr
}
# Import a .env file into envr
export def "envr backup" [
file: path
] {
cd (dirname $file);
let contents = (open $file --raw)
open db
let row = {
path: $file
dir: (pwd)
remotes: (git remote | lines | each { git remote get-url $in } | to json)
sha256: ($contents | hash sha256)
contents: $contents
};
try {
$row | stor insert -t envr_env_files
} catch {
$row | stor update -t envr_env_files -w $'path == "($row.path)"'
}
close db
$"file '($file)' backed up!"
}
const db_path = '~/.envr/data.age'
# Create or load the database
def "open db" [] {
if (not ($db_path | path exists)) {
create-db
} else {
# Open the db
let dec = mktemp -p ~/.envr;
let priv_key = ((envr config show).keys.0.private | path expand);
age -d -i $priv_key ($db_path | path expand) | save -f $dec
stor import -f $dec
rm $dec
}
stor open
}
def "create-db" []: nothing -> any {
let dec = mktemp -p ~/.envr;
sqlite3 $dec 'create table envr_env_files (
path text primary key not null
, dir text not null
, remotes text -- JSON
, sha256 text not null
, contents text not null
);'
let pub_key = ((envr config show).keys.0.public | path expand);
age -R $pub_key $dec | save -f $db_path
stor import -f $dec
rm $dec;
}
def "close db" [] {
let dec = mktemp -p ~/.envr;
stor export --file-name $dec;
# Encrypt the file
let pub_key = ((envr config show).keys.0.public | path expand);
age -R $pub_key $dec | save -f $db_path
rm $dec
}
# Restore a .env file from backup
export def "envr restore" [
path?: path # The path of the file to restore. Will be prompted if left blank.
]: nothing -> string {
let files = (files)
let $path = if ($path | is-empty) {
( (
$files ^envr list
| select path dir remotes | from json
| input list -f "Please select a file to restore" | each {
| get path [$in.directory $in.path] | path join
}
) )
} else {
$path
}
let file = ($files | where path == $path | first);
assert ($file | is-not-empty) "File must be found"
let response = if (($path | path type) == 'file') {
if (open --raw $file.path | hash sha256 | $in == $file.sha256) {
# File matches
$'(ansi yellow)file is already up to date.(ansi reset)';
} else {
# File exists, but doesn't match
let continue = (
[No Yes]
| input list $"File '($path)' already exists, are you sure you want to overwrite it?"
| $in == 'Yes'
);
if ($continue) {
null
} else {
$'(ansi yellow)No action was taken(ansi reset)'
}
}
};
if ($response | is-empty) {
# File should be restored
$file.contents | save -f $path
return $'(ansi green)($path) restored!(ansi reset)'
} else {
return $response
}
} }
# Supported config formats export def untracked-paths [] {
const available_formats = [ (
json ^envr scan
toml | from json
yaml )
ini }
xml
nuon export extern envr [
...args: any
--help(-h) # Show help information
] ]
# Create your initial config export extern "envr backup" [
export def "envr init" [ --help(-h) # Show help for backup command
format?: string path: path@untracked-paths # Path to .env file to backup
#identity?: path ]
]: nothing -> record {
mkdir ~/.envr
if (glob ~/.envr/config.* | length | $in > 0) { export extern "envr check" [
error make { --help(-h) # Show help for check command
msg: "A config file already exists" ]
label: {
text: ""
span: (metadata $format).span
}
}
} else {
let format = if ($format | is-empty) {
$available_formats | input list 'Please select the desired format for your config file'
}
# TODO: Let user select identity. export extern "envr edit-config" [
let identity = '~/.ssh/id_ed25519'; --help(-h) # Show help for edit-config command
]
# The path to the config file. export extern "envr help" [
let source = $'~/.envr/config.($format)' command?: string # Show help for specific command
]
# TODO: Let user select file types to scan for. export extern "envr init" [
--help(-h) # Show help for init command
]
{ export extern "envr list" [
source: $source --help(-h) # Show help for list command
priv_key: $identity ]
pub_key: $'($identity).pub'
scan: {
matcher: '\.env'
exclude: '*.envrc'
include: '~'
}
} | tee {
save $source;
open db
}
}
}
# Remove a .env file from your backups. export extern "envr remove" [
export def "envr remove" [ --help(-h) # Show help for remove command
...paths: path path: path@tracked-paths
] { ]
let $paths = if ($paths | is-empty) {
(envr list | get path) | input list -m "Select path(s) to remove"
} else {
$paths
};
print -e $paths; export extern "envr restore" [
--help(-h) # Show help for restore command
path: path@tracked-paths
]
let confirmed = ( export extern "envr scan" [
['No' 'Yes'] --help(-h) # Show help for scan command
| input list 'Are you sure you want to delete these backups?' ]
| $in == 'Yes'
);
if ($confirmed) { export extern "envr sync" [
let $q = ( --help(-h) # Show help for sync command
$paths ]
| each { path expand }
| to json -r
| str replace '[' '('
| str replace ']' ')'
| str replace -a '"' "'");
(
open db
| stor delete -t envr_env_files -w $'path in ($q)'
);
close db export extern "envr nushell-completion" [
--help(-h) # Show help for nushell-completion command
$'(ansi green)Paths removed!' ]
} else {
$'(ansi yellow)Operation aborted.(ansi reset)'
}
}
# View your tracked files
export def "envr list" [] {
(
files
| reject contents
| update path { $in | path relative-to ~/ | $'~/($in)' }
| update dir { $in | path relative-to ~/ | $'~/($in)' }
)
}
# List all the files in the database
def files [] {
(
open db
| query db 'select * from envr_env_files'
| update remotes { from json }
)
}
# Update or restore your env backups
export def "envr sync" [] {
let $files = (files);
$files | each { |it|
if ($it.path | path type | $in == 'file') {
if (open $it.path | hash sha256 | $in != $it.sha256) {
envr backup $it.path
} else {
'Nothing to do!'
}
} else {
envr restore $it.path
}
}
}
# Search for .env files and select ones to back up.
export def "envr scan" [] {
let config = (envr config show).scan;
let ignored_env_files = (
find env-files $config.matcher $config.exclude $config.include
);
let tracked_file_paths = (files).path;
let scanned = $ignored_env_files | where { |it|
not ($it in $tracked_file_paths)
}
(
$scanned
| input list -m "Select files to back up"
| each { envr backup $in }
)
}
# Search for hidden env files
def "find env-files" [
match: string = '\.env'
exclude: string = '*.envrc'
search: string = '~/'
]: nothing -> list<path> {
let search = ($search | path expand);
let unignored = (fd -a $match -E $exclude -H $search | lines)
let all = (fd -a $match -E $exclude -HI $search | lines)
let ignored = $all | where { |it|
not ($it in $unignored)
}
$ignored | sort
}
# Edit your config
export def "envr edit config" [] {
^$env.EDITOR (config-file)
}
def "config-file" []: [nothing -> path nothing -> nothing] {
ls ~/.envr/config.* | get 0.name -o
}
# show your current config
def "envr config show" []: nothing -> record<source: path, priv_key: path, pub_key: path> {
open (config-file)
}

201
prompt.odin Normal file
View File

@@ -0,0 +1,201 @@
package main
import "core:fmt"
import "core:sys/posix"
MultiSelect_Result :: enum {
Confirm,
Cancel,
}
Key :: enum {
Up,
Down,
Space,
Enter,
Escape,
Unknown,
}
Raw_State :: struct {
original: posix.termios,
fd: posix.FD,
}
MAX_VISIBLE :: 7
// Caller is responsible for deleting the responses.
multi_select :: proc(
prompt: string,
options: []string,
) -> (
selected: [dynamic]bool,
result: MultiSelect_Result,
) {
if len(options) == 0 {
return
}
selected = make([dynamic]bool, 0, len(options))
cursor: int = 0
scroll_offset: int = 0
fmt.printf("\x1b[?25l")
visible := render_options(prompt, options, selected[:], cursor, scroll_offset)
raw, ok := enable_raw_mode(posix.STDIN_FILENO)
if !ok {
fmt.printf("\x1b[?25h")
return
}
defer disable_raw_mode(&raw)
for {
key := read_key()
switch key {
case .Up:
if cursor > 0 {
cursor -= 1
}
case .Down:
if cursor < len(options) - 1 {
cursor += 1
}
case .Space:
selected[cursor] = !selected[cursor]
case .Enter:
fmt.printf("\x1b[%dA\x1b[J\x1b[?25h", visible + 1)
result = .Confirm
return
case .Escape:
fmt.printf("\x1b[%dA\x1b[J\x1b[?25h", visible + 1)
result = .Cancel
return
case .Unknown:
}
scroll_offset = max(0, min(cursor - MAX_VISIBLE / 2, len(options) - MAX_VISIBLE))
fmt.printf("\x1b[%dA\x1b[0J", visible + 1)
visible = render_options(prompt, options, selected[:], cursor, scroll_offset)
}
}
render_options :: proc(
prompt: string,
options: []string,
selected: []bool,
cursor: int,
scroll_offset: int,
) -> int {
fmt.printf("\x1b[1;36m%s\x1b[0m (↑/↓ move, space select, enter confirm)\r\n", prompt)
end := scroll_offset + MAX_VISIBLE
if end > len(options) {
end = len(options)
}
for i in scroll_offset ..< end {
checkbox := " "
if selected[i] {
checkbox = "x"
}
if i == cursor {
fmt.printf("\x1b[1;32m> \x1b[0m[\x1b[32m%s\x1b[0m] %s\r\n", checkbox, options[i])
} else {
fmt.printf(" [\x1b[2m%s\x1b[0m] %s\r\n", checkbox, options[i])
}
}
return end - scroll_offset
}
enable_raw_mode :: proc(fd: posix.FD) -> (Raw_State, bool) {
state: Raw_State
state.fd = fd
if posix.tcgetattr(fd, &state.original) != .OK {
return state, false
}
attr: posix.termios = state.original
attr.c_lflag -= {.ICANON, .ECHO, .ISIG, .IEXTEN}
attr.c_iflag -= {.IXON, .ICRNL, .BRKINT, .INPCK, .ISTRIP}
attr.c_oflag -= {.OPOST}
attr.c_cflag += {.CS8}
attr.c_cc[.VMIN] = 1
attr.c_cc[.VTIME] = 0
if posix.tcsetattr(fd, .TCSAFLUSH, &attr) != .OK {
return state, false
}
return state, true
}
disable_raw_mode :: proc(state: ^Raw_State) {
posix.tcsetattr(state.fd, .TCSAFLUSH, &state.original)
}
read_key :: proc() -> Key {
buf: [3]u8
n := posix.read(posix.STDIN_FILENO, &buf[0], 1)
if n <= 0 {
return .Unknown
}
switch buf[0] {
case ' ':
return .Space
case '\n', '\r':
return .Enter
case 0x03:
return .Escape
case 0x1b:
tv: posix.timeval
tv.tv_sec = 0
tv.tv_usec = posix.suseconds_t(100000)
set: posix.fd_set
posix.FD_ZERO(&set)
posix.FD_SET(posix.STDIN_FILENO, &set)
ready := posix.select(1, &set, nil, nil, &tv)
if ready <= 0 {
return .Escape
}
n2 := posix.read(posix.STDIN_FILENO, &buf[1], 1)
if n2 <= 0 || buf[1] != '[' {
return .Escape
}
posix.FD_ZERO(&set)
posix.FD_SET(posix.STDIN_FILENO, &set)
tv.tv_sec = 0
tv.tv_usec = posix.suseconds_t(100000)
ready = posix.select(1, &set, nil, nil, &tv)
if ready <= 0 {
return .Escape
}
n3 := posix.read(posix.STDIN_FILENO, &buf[2], 1)
if n3 <= 0 {
return .Escape
}
switch buf[2] {
case 'A':
return .Up
case 'B':
return .Down
case:
return .Escape
}
case:
return .Unknown
}
}

31
scan.odin Normal file
View File

@@ -0,0 +1,31 @@
package main
import "core:os"
import "findr"
// Caller is responsible for freeing paths
scan_path :: proc(search_path: string, cfg: Config) -> (paths: [dynamic]string, ok: bool) {
opts := findr.WalkOptions {
pattern = cfg.ScanConfig.Matcher,
excludes = cfg.ScanConfig.Exclude[:],
}
findr.walk({search_path}, &paths, opts, os.get_processor_core_count())
ok = true
return
}
find_unbacked :: proc(local_files: []string, db_files: []EnvFile) -> []string {
backed_set := make(map[string]bool, len(db_files), context.temp_allocator)
for file in db_files {
backed_set[file.Path] = true
}
unbacked := make([dynamic]string, 0, len(db_files) / 2, context.temp_allocator)
for file in local_files {
if !(file in backed_set) {
append(&unbacked, file)
}
}
return unbacked[:]
}

85
scan_test.odin Normal file
View File

@@ -0,0 +1,85 @@
package main
import "core:fmt"
import "core:os"
import "core:path/filepath"
import "core:testing"
@(test)
test_scan_path_finds_gitignored_env_files :: proc(t: ^testing.T) {
base := fmt.tprintf("/tmp/envr-scan-test-%d", os.get_pid())
os.mkdir_all(base)
defer os.remove_all(base)
git_init := os.Process_Desc {
command = []string{"git", "-c", "advice.defaultBranchName=false", "init", "-q"},
working_dir = base,
stdout = os.stderr,
stderr = os.stderr,
}
p, err := os.process_start(git_init)
if err != nil {
return
}
_, wait_err := os.process_wait(p)
if wait_err != nil {
return
}
gitignore_path := fmt.tprintf("%s/.gitignore", base)
_ = os.write_entire_file(gitignore_path, ".env*\n")
_ = os.write_entire_file(fmt.tprintf("%s/.env", base), "SECRET=1")
_ = os.write_entire_file(fmt.tprintf("%s/.env.testing", base), "TEST=1")
_ = os.write_entire_file(fmt.tprintf("%s/config.yaml", base), "key: value")
cfg := Config {
ScanConfig = ScanConfig{Matcher = "\\.env"},
}
results, ok := scan_path(base, cfg)
defer {
for path in results {
delete(path)
}
delete(results)
}
testing.expect(t, ok, "scan_path should succeed")
found_env := false
found_testing := false
found_config := false
for path in results {
_, filename := filepath.split(path)
if filename == ".env" {
found_env = true
}
if filename == ".env.testing" {
found_testing = true
}
if filename == "config.yaml" {
found_config = true
}
}
testing.expect(t, found_env, "should find .env (gitignored)")
testing.expect(t, found_testing, "should find .env.testing (gitignored)")
testing.expect(t, !found_config, "should NOT find config.yaml (not gitignored)")
}
@(test)
test_scan_path_empty_dir :: proc(t: ^testing.T) {
base := fmt.tprintf("/tmp/envr-scan-empty-%d", os.get_pid())
os.mkdir_all(base)
defer os.remove_all(base)
cfg := Config {
ScanConfig = ScanConfig{Matcher = "\\.env"},
}
results, ok := scan_path(base, cfg)
defer delete(results)
testing.expect(t, ok, "scan_path should succeed")
testing.expect(t, len(results) == 0, fmt.tprintf("expected 0 results, got %d", len(results)))
}

31
sodium.odin Normal file
View File

@@ -0,0 +1,31 @@
package main
import "core:c"
foreign import libsodium "system:sodium"
CRYPTO_BOX_PUBLICKEY_BYTES :: 32
CRYPTO_BOX_SECRETKEY_BYTES :: 32
CRYPTO_BOX_NONCE_BYTES :: 24
CRYPTO_BOX_MAC_BYTES :: 16
CRYPTO_SECRETBOX_KEY_BYTES :: 32
CRYPTO_SECRETBOX_NONCE_BYTES :: 24
CRYPTO_SECRETBOX_MAC_BYTES :: 16
CRYPTO_SIGN_PUBLICKEY_BYTES :: 32
CRYPTO_SIGN_SECRETKEY_BYTES :: 64
@(default_calling_convention = "c")
foreign libsodium {
sodium_init :: proc() -> c.int ---
// crypto_box_keypair :: proc(pk: [^]u8, sk: [^]u8) -> c.int ---
crypto_box_easy :: proc(ciphertext: [^]u8, plaintext: [^]u8, mlen: c.ulong, nonce: [^]u8, pk: [^]u8, sk: [^]u8) -> c.int ---
crypto_box_open_easy :: proc(plaintext: [^]u8, ciphertext: [^]u8, clen: c.ulong, nonce: [^]u8, pk: [^]u8, sk: [^]u8) -> c.int ---
crypto_secretbox_easy :: proc(ciphertext: [^]u8, plaintext: [^]u8, mlen: c.ulong, nonce: [^]u8, key: [^]u8) -> c.int ---
crypto_secretbox_open_easy :: proc(plaintext: [^]u8, ciphertext: [^]u8, clen: c.ulong, nonce: [^]u8, key: [^]u8) -> c.int ---
crypto_sign_ed25519_pk_to_curve25519 :: proc(curve25519_pk: [^]u8, ed25519_pk: [^]u8) -> c.int ---
crypto_sign_ed25519_sk_to_curve25519 :: proc(curve25519_sk: [^]u8, ed25519_sk: [^]u8) -> c.int ---
randombytes_buf :: proc(buf: [^]u8, size: c.ulong) ---
}

45
sqlite/sqlite.odin Normal file
View File

@@ -0,0 +1,45 @@
package sqlite
import "core:c"
foreign import lib "system:sqlite3"
OK :: 0
ROW :: 100
DONE :: 101
DESERIALIZE_FREEONCLOSE :: 1
DESERIALIZE_RESIZEABLE :: 2
foreign lib {
@(link_name="sqlite3_open")
db_open :: proc(filename: cstring, ppDb: ^^rawptr) -> c.int ---
@(link_name="sqlite3_close")
db_close :: proc(db: ^rawptr) -> c.int ---
@(link_name="sqlite3_errmsg")
db_errmsg :: proc(db: ^rawptr) -> cstring ---
@(link_name="sqlite3_exec")
db_exec :: proc(db: ^rawptr, sql: cstring, callback: rawptr, callback_arg: rawptr, errmsg: ^cstring) -> c.int ---
@(link_name="sqlite3_prepare_v2")
prepare_v2 :: proc(db: ^rawptr, sql: cstring, nByte: c.int, ppStmt: ^^rawptr, pzTail: ^cstring) -> c.int ---
@(link_name="sqlite3_step")
step :: proc(stmt: ^rawptr) -> c.int ---
@(link_name="sqlite3_finalize")
finalize :: proc(stmt: ^rawptr) -> c.int ---
@(link_name="sqlite3_column_text")
column_text :: proc(stmt: ^rawptr, iCol: c.int) -> cstring ---
@(link_name="sqlite3_column_bytes")
column_bytes :: proc(stmt: ^rawptr, iCol: c.int) -> c.int ---
@(link_name="sqlite3_bind_text")
bind_text :: proc(stmt: ^rawptr, idx: c.int, val: cstring, n: c.int, destructor: rawptr) -> c.int ---
@(link_name="sqlite3_changes")
changes :: proc(db: ^rawptr) -> c.int ---
@(link_name="sqlite3_serialize")
serialize :: proc(db: ^rawptr, zSchema: cstring, piSize: ^i64, mFlags: u32) -> [^]u8 ---
@(link_name="sqlite3_deserialize")
deserialize :: proc(db: ^rawptr, zSchema: cstring, pData: [^]u8, szDb: i64, szBuf: i64, mFlags: u32) -> c.int ---
@(link_name="sqlite3_malloc64")
malloc64 :: proc(n: i64) -> [^]u8 ---
@(link_name="sqlite3_free")
free :: proc(p: rawptr) ---
}

255
ssh.odin Normal file
View File

@@ -0,0 +1,255 @@
package main
import "core:encoding/base64"
import "core:fmt"
import "core:os"
import "core:strings"
SSH_ED25519 :: "ssh-ed25519"
Ed25519Keypair :: struct {
Public: [32]u8,
Private: [32]u8,
}
parse_ssh_public_key :: proc(pub_path: string) -> (pub: [32]u8, ok: bool) {
data, err := os.read_entire_file_from_path(pub_path, context.temp_allocator)
if err != nil {
return
}
text := strings.trim_right(string(data), "\n")
parts := strings.split(text, " ", context.temp_allocator)
if len(parts) < 2 {
return
}
if parts[0] != SSH_ED25519 {
return
}
decoded, decode_err := base64.decode(parts[1], allocator = context.temp_allocator)
if decode_err != nil || len(decoded) < 51 {
return
}
offset := 0
key_type, type_ok := read_wire_string(decoded, &offset)
if !type_ok || key_type != SSH_ED25519 {
return
}
pk_data, pk_ok := read_wire_string(decoded, &offset)
if !pk_ok || len(pk_data) != 32 {
return
}
for i in 0 ..< 32 {
pub[i] = pk_data[i]
}
ok = true
return
}
parse_ssh_private_key :: proc(priv_path: string) -> (kp: Ed25519Keypair, ok: bool) {
data, err := os.read_entire_file_from_path(priv_path, context.temp_allocator)
if err != nil {
return
}
text := string(data)
lines := strings.split(text, "\n", context.temp_allocator)
b: strings.Builder
strings.builder_init(&b, context.temp_allocator)
defer strings.builder_destroy(&b)
in_block := false
for line in lines {
trimmed := strings.trim_space(line)
if trimmed == "-----BEGIN OPENSSH PRIVATE KEY-----" {
in_block = true
continue
}
if trimmed == "-----END OPENSSH PRIVATE KEY-----" {
break
}
if in_block && len(trimmed) > 0 {
fmt.sbprintf(&b, "%s", trimmed)
}
}
b64_str := strings.to_string(b)
decoded, decode_err := base64.decode(b64_str, allocator = context.temp_allocator)
if decode_err != nil {
return
}
magic := "openssh-key-v1\x00"
if len(decoded) < len(magic) {
return
}
for i in 0 ..< len(magic) {
if decoded[i] != u8(magic[i]) {
return
}
}
offset := len(magic)
ciphername, cipher_ok := read_wire_string(decoded, &offset)
if !cipher_ok || ciphername != "none" {
return
}
kdfname, kdf_ok := read_wire_string(decoded, &offset)
if !kdf_ok || kdfname != "none" {
return
}
_, opts_ok := read_wire_string(decoded, &offset)
if !opts_ok {
return
}
if offset + 4 > len(decoded) {
return
}
num_keys := u32(decoded[offset]) << 24 | u32(decoded[offset + 1]) << 16 |
u32(decoded[offset + 2]) << 8 | u32(decoded[offset + 3])
offset += 4
if num_keys != 1 {
return
}
_, pub_blob_ok := read_wire_string(decoded, &offset)
if !pub_blob_ok {
return
}
priv_blob, priv_blob_ok := read_wire_string(decoded, &offset)
if !priv_blob_ok {
return
}
inner_offset := 0
if inner_offset + 8 > len(priv_blob) {
return
}
check1 := u32(priv_blob[inner_offset]) << 24 | u32(priv_blob[inner_offset + 1]) << 16 |
u32(priv_blob[inner_offset + 2]) << 8 | u32(priv_blob[inner_offset + 3])
inner_offset += 4
check2 := u32(priv_blob[inner_offset]) << 24 | u32(priv_blob[inner_offset + 1]) << 16 |
u32(priv_blob[inner_offset + 2]) << 8 | u32(priv_blob[inner_offset + 3])
inner_offset += 4
if check1 != check2 {
return
}
priv_type, type_ok := read_wire_string(transmute([]u8)priv_blob, &inner_offset)
if !type_ok || priv_type != SSH_ED25519 {
return
}
pub_wire, pub_ok := read_wire_string(transmute([]u8)priv_blob, &inner_offset)
if !pub_ok || len(pub_wire) != 32 {
return
}
for i in 0 ..< 32 {
kp.Public[i] = pub_wire[i]
}
priv_wire, priv_ok := read_wire_string(transmute([]u8)priv_blob, &inner_offset)
if !priv_ok || len(priv_wire) != 64 {
return
}
for i in 0 ..< 32 {
kp.Private[i] = priv_wire[i]
}
ok = true
return
}
is_ed25519_key :: proc(priv_path: string) -> bool {
pub_path, _ := strings.concatenate([]string{priv_path, ".pub"}, context.temp_allocator)
_, ok := parse_ssh_public_key(pub_path)
return ok
}
is_encrypted_key :: proc(priv_path: string) -> bool {
data, err := os.read_entire_file_from_path(priv_path, context.temp_allocator)
if err != nil {
return true
}
if !strings.contains(string(data), "BEGIN OPENSSH PRIVATE KEY") {
return true
}
text := string(data)
lines := strings.split(text, "\n", context.temp_allocator)
b2: strings.Builder
strings.builder_init(&b2, context.temp_allocator)
defer strings.builder_destroy(&b2)
in_block := false
for line in lines {
trimmed := strings.trim_space(line)
if trimmed == "-----BEGIN OPENSSH PRIVATE KEY-----" {
in_block = true
continue
}
if trimmed == "-----END OPENSSH PRIVATE KEY-----" {
break
}
if in_block && len(trimmed) > 0 {
fmt.sbprintf(&b2, "%s", trimmed)
}
}
b64_str := strings.to_string(b2)
decoded, decode_err := base64.decode(b64_str, allocator = context.temp_allocator)
if decode_err != nil {
return true
}
magic := "openssh-key-v1\x00"
if len(decoded) < len(magic) {
return true
}
for i in 0 ..< len(magic) {
if decoded[i] != u8(magic[i]) {
return true
}
}
offset := len(magic)
ciphername, cipher_ok := read_wire_string(decoded, &offset)
if !cipher_ok {
return true
}
return ciphername != "none"
}
read_wire_string :: proc(data: []u8, offset: ^int) -> (s: string, ok: bool) {
if offset^ + 4 > len(data) {
return
}
length := u32(data[offset^]) << 24 | u32(data[offset^ + 1]) << 16 |
u32(data[offset^ + 2]) << 8 | u32(data[offset^ + 3])
offset^ += 4
if offset^ + int(length) > len(data) {
return
}
s = string(data[offset^ : offset^ + int(length)])
offset^ += int(length)
ok = true
return
}

108
ssh_test.odin Normal file
View File

@@ -0,0 +1,108 @@
package main
import "core:fmt"
import "core:testing"
TEST_KEY_DIR :: "fixtures/keys"
@(test)
test_parse_ed25519_public_key :: proc(t: ^testing.T) {
pub, ok := parse_ssh_public_key(TEST_KEY_DIR + "/test_ed25519.pub")
testing.expect(t, ok, "expected ed25519 public key to parse")
testing.expect(t, pub != [32]u8{}, fmt.tprintf("expected non-zero public key"))
}
@(test)
test_parse_ed25519_private_key :: proc(t: ^testing.T) {
kp, ok := parse_ssh_private_key(TEST_KEY_DIR + "/test_ed25519")
testing.expect(t, ok, "expected ed25519 private key to parse")
testing.expect(t, kp.Public != [32]u8{}, "expected non-zero public key")
testing.expect(t, kp.Private != [32]u8{}, "expected non-zero private key")
}
@(test)
test_parse_rsa_public_key_fails :: proc(t: ^testing.T) {
_, ok := parse_ssh_public_key(TEST_KEY_DIR + "/test_rsa.pub")
testing.expect(t, !ok, "expected RSA key parsing to fail")
}
@(test)
test_is_ed25519_key_true :: proc(t: ^testing.T) {
testing.expect(t, is_ed25519_key(TEST_KEY_DIR + "/test_ed25519"))
}
@(test)
test_is_ed25519_key_false_for_rsa :: proc(t: ^testing.T) {
testing.expect(t, !is_ed25519_key(TEST_KEY_DIR + "/test_rsa"))
}
@(test)
test_private_key_pub_matches_public_key :: proc(t: ^testing.T) {
pub_from_pub, pub_ok := parse_ssh_public_key(TEST_KEY_DIR + "/test_ed25519.pub")
testing.expect(t, pub_ok, "expected public key to parse")
kp, priv_ok := parse_ssh_private_key(TEST_KEY_DIR + "/test_ed25519")
testing.expect(t, priv_ok, "expected private key to parse")
testing.expect(
t,
pub_from_pub == kp.Public,
fmt.tprintf(
"public key mismatch:\n from .pub: %v\n from priv: %v",
pub_from_pub,
kp.Public,
),
)
}
@(test)
test_read_wire_string :: proc(t: ^testing.T) {
data := []u8{0, 0, 0, 5, u8('h'), u8('e'), u8('l'), u8('l'), u8('o'), 0, 0, 0, 0}
offset := 0
s, ok := read_wire_string(data, &offset)
testing.expect(t, ok, "expected read_wire_string to succeed")
testing.expect(t, s == "hello", fmt.tprintf("expected 'hello', got %q", s))
testing.expect(t, offset == 9, fmt.tprintf("expected offset 9, got %d", offset))
s2, ok2 := read_wire_string(data, &offset)
testing.expect(t, ok2, "expected second read to succeed")
testing.expect(t, s2 == "", "expected empty string")
}
@(test)
test_is_encrypted_key_encrypted :: proc(t: ^testing.T) {
testing.expect(
t,
is_encrypted_key(TEST_KEY_DIR + "/test_ed25519_encrypted"),
"encrypted key should be detected as encrypted",
)
}
@(test)
test_is_encrypted_key_unencrypted :: proc(t: ^testing.T) {
testing.expect(
t,
!is_encrypted_key(TEST_KEY_DIR + "/test_ed25519"),
"unencrypted key should not be detected as encrypted",
)
}
@(test)
test_is_encrypted_key_rsa_unencrypted :: proc(t: ^testing.T) {
testing.expect(
t,
!is_encrypted_key(TEST_KEY_DIR + "/test_rsa"),
"unencrypted RSA key should not be detected as encrypted",
)
}
@(test)
test_is_encrypted_key_missing_file :: proc(t: ^testing.T) {
testing.expect(
t,
is_encrypted_key(TEST_KEY_DIR + "/nonexistent"),
"missing file should be treated as encrypted (fail-safe)",
)
}

89
table.odin Normal file
View File

@@ -0,0 +1,89 @@
package main
import "core:encoding/json"
import "core:fmt"
import "core:io"
import "core:strings"
render_table :: proc(w: io.Writer, headers: []string, rows: [][]string) {
col_widths := make([dynamic]int, 0, len(headers))
for i in 0 ..< len(headers) {
append(&col_widths, strings.rune_count(headers[i]))
}
for r in rows {
for i in 0 ..< len(r) {
rw := strings.rune_count(r[i])
if i < len(col_widths) && rw > col_widths[i] {
col_widths[i] = rw
}
}
}
b: strings.Builder
strings.builder_init(&b)
defer strings.builder_destroy(&b)
defer delete(col_widths)
hline :: proc(w: io.Writer, b: ^strings.Builder, left, mid, right: string, widths: [dynamic]int) {
strings.write_string(b, left)
for i in 0 ..< len(widths) {
for _ in 0 ..< widths[i] + 2 {
strings.write_string(b, "\u2500")
}
if i < len(widths) - 1 {
strings.write_string(b, mid)
} else {
strings.write_string(b, right)
}
}
fmt.wprintf(w, "%s\n", strings.to_string(b^), flush = false)
strings.builder_reset(b)
}
hline(w, &b, "\u250c", "\u252c", "\u2510", col_widths)
cell :: proc(b: ^strings.Builder, s: string, width: int) {
extra := len(s) - strings.rune_count(s)
fmt.sbprintf(b, " %-*s \u2502", width + extra, s)
}
strings.write_string(&b, "\u2502")
for i in 0 ..< len(headers) {
cell(&b, headers[i], col_widths[i])
}
fmt.wprintf(w, "%s\n", strings.to_string(b), flush = false)
strings.builder_reset(&b)
hline(w, &b, "\u251c", "\u253c", "\u2524", col_widths)
for r in rows {
strings.write_string(&b, "\u2502")
for i in 0 ..< len(r) {
cell(&b, r[i], col_widths[i])
}
fmt.wprintf(w, "%s\n", strings.to_string(b), flush = false)
strings.builder_reset(&b)
}
hline(w, &b, "\u2514", "\u2534", "\u2518", col_widths)
}
render_json_rows :: proc(w: io.Writer, headers: []string, rows: [][]string) {
entries := make([dynamic]map[string]string, 0, len(rows), context.temp_allocator)
for row in rows {
entry := make(map[string]string, len(headers), context.temp_allocator)
for i in 0 ..< len(headers) {
entry[headers[i]] = row[i]
}
append(&entries, entry)
}
data, err := json.marshal(entries[:], allocator = context.temp_allocator)
if err != nil {
fmt.eprintf("Error marshaling JSON: %v\n", err)
return
}
fmt.wprintf(w, "%s", data, flush = false)
}

198
table_test.odin Normal file
View File

@@ -0,0 +1,198 @@
package main
import "core:encoding/json"
import "core:fmt"
import "core:strings"
import "core:testing"
@(test)
test_render_json_rows_normal :: proc(t: ^testing.T) {
b: strings.Builder
strings.builder_init(&b)
defer strings.builder_destroy(&b)
headers := []string{"name", "path"}
rows := [][]string{{"foo", "/home/user/.env"}, {"bar", "/home/user/project/.env"}}
w := strings.to_writer(&b)
render_json_rows(w, headers, rows)
output := strings.to_string(b)
result: []map[string]string = ---
unmarshal_err := json.unmarshal_string(output, &result, allocator = context.temp_allocator)
testing.expect(
t,
unmarshal_err == nil,
fmt.tprintf("json unmarshal failed: %v\noutput was: %q", unmarshal_err, output),
)
testing.expect(t, len(result) == 2, fmt.tprintf("expected 2 rows, got %d", len(result)))
testing.expect(
t,
result[0]["name"] == "foo",
fmt.tprintf("expected name=foo, got %q", result[0]["name"]),
)
testing.expect(t, result[0]["path"] == "/home/user/.env")
testing.expect(t, result[1]["name"] == "bar")
testing.expect(t, result[1]["path"] == "/home/user/project/.env")
}
@(test)
test_render_json_rows_special_chars :: proc(t: ^testing.T) {
b: strings.Builder
strings.builder_init(&b)
defer strings.builder_destroy(&b)
headers := []string{"key", "value"}
rows := [][]string {
{"quote", `has "double quotes"`},
{"backslash", `path\to\file`},
{"newline", "line1\nline2"},
{"mixed", `a "b" c\nd`},
}
w := strings.to_writer(&b)
render_json_rows(w, headers, rows)
output := strings.to_string(b)
result: []map[string]string = ---
unmarshal_err := json.unmarshal(
transmute([]byte)output,
&result,
allocator = context.temp_allocator,
)
testing.expect(
t,
unmarshal_err == nil,
fmt.tprintf("json unmarshal failed: %v\noutput was: %q", unmarshal_err, output),
)
testing.expect(t, len(result) == 4)
testing.expect(
t,
result[0]["value"] == `has "double quotes"`,
fmt.tprintf("got %q", result[0]["value"]),
)
testing.expect(t, result[1]["value"] == `path\to\file`)
testing.expect(t, result[2]["value"] == "line1\nline2")
testing.expect(t, result[3]["value"] == `a "b" c\nd`)
}
@(test)
test_render_json_rows_empty :: proc(t: ^testing.T) {
b: strings.Builder
strings.builder_init(&b)
defer strings.builder_destroy(&b)
headers := []string{"name"}
rows: [][]string
w := strings.to_writer(&b)
render_json_rows(w, headers, rows)
output := strings.to_string(b)
result: []map[string]string = ---
unmarshal_err := json.unmarshal_string(output, &result, allocator = context.temp_allocator)
testing.expect(
t,
unmarshal_err == nil,
fmt.tprintf("json unmarshal failed: %v\noutput was: %q", unmarshal_err, output),
)
testing.expect(t, len(result) == 0)
}
@(test)
test_render_table_normal :: proc(t: ^testing.T) {
b: strings.Builder
strings.builder_init(&b)
defer strings.builder_destroy(&b)
headers := []string{"Name", "Path"}
rows := [][]string{{"foo", "/home/user/.env"}, {"bar", "/home/user/project/.env"}}
w := strings.to_writer(&b)
render_table(w, headers, rows)
output := strings.to_string(b)
expected := `┌──────┬─────────────────────────┐
│ Name │ Path │
├──────┼─────────────────────────┤
│ foo │ /home/user/.env │
│ bar │ /home/user/project/.env │
└──────┴─────────────────────────┘
`
testing.expect(
t,
output == expected,
fmt.tprintf(
"table output mismatch\n--- expected ---\n%s\n--- got ---\n%s\n",
expected,
output,
),
)
}
@(test)
test_render_table_empty :: proc(t: ^testing.T) {
b: strings.Builder
strings.builder_init(&b)
defer strings.builder_destroy(&b)
headers := []string{"Name"}
rows: [][]string
w := strings.to_writer(&b)
render_table(w, headers, rows)
output := strings.to_string(b)
expected := `┌──────┐
│ Name │
├──────┤
└──────┘
`
testing.expect(
t,
output == expected,
fmt.tprintf(
"table output mismatch\n--- expected ---\n%s\n--- got ---\n%s\n",
expected,
output,
),
)
}
@(test)
test_render_table_unicode :: proc(t: ^testing.T) {
b: strings.Builder
strings.builder_init(&b)
defer strings.builder_destroy(&b)
headers := []string{"Status", "Detail"}
rows := [][]string{{"\u2713 Available", "ok"}, {"\u2717 Missing", "fail"}}
w := strings.to_writer(&b)
render_table(w, headers, rows)
output := strings.to_string(b)
expected := `┌─────────────┬────────┐
│ Status │ Detail │
├─────────────┼────────┤
│ ✓ Available │ ok │
│ ✗ Missing │ fail │
└─────────────┴────────┘
`
testing.expect(
t,
output == expected,
fmt.tprintf(
"table output mismatch\n--- expected ---\n%s\n--- got ---\n%s\n",
expected,
output,
),
)
}

1
version.txt Normal file
View File

@@ -0,0 +1 @@
0.4.0