37 Commits

Author SHA1 Message Date
83acf00d85 test: Added missing tests. 2026-06-14 22:42:16 -04:00
46c2baf726 fix: Fixed cli command. 2026-06-14 21:59:54 -04:00
a56d349da4 docs: Added plan for improving testing. 2026-06-14 21:52:26 -04:00
2229affe69 refactor: Fixed duplicate terminal checks. 2026-06-14 21:36:31 -04:00
6394c42d8b docs: Created table improvement plan. 2026-06-14 21:27:24 -04:00
8e00c78f12 style: Removed unused code. 2026-06-14 21:27:23 -04:00
8d2c5403e8 fix: Removed duplicate insert calls. 2026-06-14 21:27:23 -04:00
7c7d3d5c23 fix: Fixed logic bug in db. 2026-06-14 21:27:23 -04:00
6151a5efaf ci: Updated release-please. 2026-06-14 21:27:23 -04:00
fbb1f86945 docs: updated README.md 2026-06-14 21:27:23 -04:00
0c5aa74256 ci: Updated github action. 2026-06-14 21:27:23 -04:00
eed360895f feat: Removed go code. 2026-06-12 16:35:39 -04:00
75b778453f build: Converted Makefile and flake package. 2026-06-12 16:35:34 -04:00
4ec2b22b52 refactor: removed is_tty. 2026-06-12 15:54:44 -04:00
0276db767e refactor: Switched from age to libsodium. 2026-06-12 15:48:12 -04:00
a0e2c99581 docs: Updated TODOs. 2026-06-12 15:36:10 -04:00
d0dc93ab56 feat(odin): Migrated nushell-completion command to go. 2026-06-12 15:01:50 -04:00
91ada61c06 feat: Added tests. 2026-06-12 14:50:42 -04:00
9b39567720 fix: Fixed the rest of the (tested) leaks. 2026-06-12 14:17:56 -04:00
43dd8aca13 perf: Improved writer performance. 2026-06-12 13:37:09 -04:00
db1b863e7e fix: fixing leaks. 2026-06-12 13:25:50 -04:00
e966050137 fix: Added proper help text to all commands. 2026-06-12 10:45:43 -04:00
7629dd2ce7 fix: Got rid of go fallback code. 2026-06-12 10:28:41 -04:00
7c7ddf46f6 fix: Fixed memory leaks in find_binary. 2026-06-12 10:22:21 -04:00
a1e945a630 feat(odin): Ported init command. 2026-06-12 10:22:21 -04:00
0a332adfdf feat(odin): Ported scan command. 2026-06-12 09:12:55 -04:00
4e1e359076 feat(odin): port check command to odin. 2026-06-12 08:27:14 -04:00
82bec68bd1 fix: Fixing AI oopsies. 2026-06-12 08:02:08 -04:00
2cb6067a3a feat(odin): ported edit-config command to odin. 2026-06-11 21:26:59 -04:00
3668df57d1 feat(odin): ported restore command to odin. 2026-06-11 21:25:11 -04:00
d2127e4780 feat(odin): Ported remove command. 2026-06-11 21:21:59 -04:00
cb7db96781 feat(odin): Added long text and --help flags. 2026-06-11 21:17:52 -04:00
c92155a17b feat(odin): ported backup command. 2026-06-11 21:14:11 -04:00
b1d2416182 feat(odin): ported list command. 2026-06-11 21:05:39 -04:00
40f0b3c36d feat(odin): ported deps command, added utilities (features, tty, table). 2026-06-11 21:05:33 -04:00
d84e43d044 odin: scaffold project with CLI parser, version command, Go fallback 2026-06-11 20:34:53 -04:00
28f96df4c0 feat: Started odin setup. 2026-06-11 20:08:27 -04:00
143 changed files with 2309 additions and 34750 deletions

3
.envrc
View File

@@ -1,4 +1 @@
use flake use flake
ROOT="/home/spencer/github.com/envr-zig"
export PATH=".:${ROOT}/deps/zig:${ROOT}/deps/zls:$PATH"

View File

@@ -1,28 +0,0 @@
# This workflow will build a golang project
# For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-go
name: Go
on:
push:
branches: [ "main" ]
pull_request:
branches: [ "main" ]
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up Go
uses: actions/setup-go@v4
with:
go-version: '1.24.6'
- name: Build
run: go build -v ./...
- name: Test
run: go test -v ./...

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 .

View File

@@ -2,6 +2,8 @@ on:
push: push:
branches: branches:
- main - main
- dev
- odin
permissions: permissions:
contents: write contents: write
@@ -14,7 +16,7 @@ jobs:
release-please: release-please:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: googleapis/release-please-action@v4 - uses: googleapis/release-please-action@v5
with: with:
# this assumes that you have created a personal access token # this assumes that you have created a personal access token
# (PAT) and configured it as a GitHub action secret named # (PAT) and configured it as a GitHub action secret named
@@ -22,4 +24,4 @@ jobs:
token: ${{ secrets.MY_RELEASE_PLEASE_TOKEN }} token: ${{ secrets.MY_RELEASE_PLEASE_TOKEN }}
# this is a built-in strategy in release-please, see "Action Inputs" # this is a built-in strategy in release-please, see "Action Inputs"
# for more options # for more options
release-type: go release-type: simple

8
.gitignore vendored
View File

@@ -1,18 +1,14 @@
# dev env # dev env
.direnv .direnv
/.env
# dependencies list.json
deps
vendor
# docs # docs
man man
# build artifacts # build artifacts
.zig-cache
builds builds
envr envr
envr-go envr-go
result result
zig-pkg version.odin

View File

@@ -4,7 +4,6 @@
APP_NAME := envr APP_NAME := envr
VERSION := $(shell grep 'version = ' flake.nix | head -1 | sed 's/.*version = "\(.*\)";/\1/') VERSION := $(shell grep 'version = ' flake.nix | head -1 | sed 's/.*version = "\(.*\)";/\1/')
BUILD_DIR := builds BUILD_DIR := builds
LDFLAGS := -X github.com/sbrow/envr/cmd.version=v$(VERSION) -s -w
# Binary names # Binary names
LINUX_AMD64_BIN := $(BUILD_DIR)/$(APP_NAME)-$(VERSION)-linux-amd64 LINUX_AMD64_BIN := $(BUILD_DIR)/$(APP_NAME)-$(VERSION)-linux-amd64
@@ -23,23 +22,23 @@ $(BUILD_DIR):
# Build Linux AMD64 # Build Linux AMD64
$(LINUX_AMD64_BIN): $(BUILD_DIR) $(LINUX_AMD64_BIN): $(BUILD_DIR)
@echo "Building for Linux AMD64..." @echo "Building for Linux AMD64..."
GOOS=linux GOARCH=amd64 CGO_ENABLED=0 go build -ldflags "$(LDFLAGS)" -o $(LINUX_AMD64_BIN) . odin build . -target:linux_amd64 -o:speed -out:$(LINUX_AMD64_BIN)
@echo "Built $(LINUX_AMD64_BIN)" @echo "Built $(LINUX_AMD64_BIN)"
# Build Linux ARM64 # Build Linux ARM64
$(LINUX_ARM64_BIN): $(BUILD_DIR) $(LINUX_ARM64_BIN): $(BUILD_DIR)
@echo "Building for Linux ARM64..." @echo "Building for Linux ARM64..."
GOOS=linux GOARCH=arm64 CGO_ENABLED=0 go build -ldflags "$(LDFLAGS)" -o $(LINUX_ARM64_BIN) . odin build . -target:linux_arm64 -o:speed -out:$(LINUX_ARM64_BIN)
@echo "Built $(LINUX_ARM64_BIN)" @echo "Built $(LINUX_ARM64_BIN)"
# Build Darwin ARM64 (Mac) # Build Darwin ARM64 (Mac)
$(DARWIN_ARM64_BIN): $(BUILD_DIR) $(DARWIN_ARM64_BIN): $(BUILD_DIR)
@echo "Building for Darwin ARM64..." @echo "Building for Darwin ARM64..."
GOOS=darwin GOARCH=arm64 CGO_ENABLED=0 go build -ldflags "$(LDFLAGS)" -o $(DARWIN_ARM64_BIN) . odin build . -target:darwin_arm64 -o:speed -out:$(DARWIN_ARM64_BIN)
@echo "Built $(DARWIN_ARM64_BIN)" @echo "Built $(DARWIN_ARM64_BIN)"
# Build all binaries # Build all binaries
build-linux: $(LINUX_AMD64_BIN) $(LINUX_ARM64_BIN) build-linux: $(LINUX_AMD64_BIN) # $(LINUX_ARM64_BIN)
build-darwin: $(DARWIN_ARM64_BIN) build-darwin: $(DARWIN_ARM64_BIN)
# Compress Linux artifacts with gzip # Compress Linux artifacts with gzip
@@ -58,11 +57,12 @@ $(BUILD_DIR)/$(APP_NAME)-$(VERSION)-darwin-arm64.zip: $(DARWIN_ARM64_BIN)
# Compress all artifacts # Compress all artifacts
compress: $(BUILD_DIR)/$(APP_NAME)-$(VERSION)-linux-amd64.tar.gz \ compress: $(BUILD_DIR)/$(APP_NAME)-$(VERSION)-linux-amd64.tar.gz \
$(BUILD_DIR)/$(APP_NAME)-$(VERSION)-linux-arm64.tar.gz \ # $(BUILD_DIR)/$(APP_NAME)-$(VERSION)-linux-arm64.tar.gz \
$(BUILD_DIR)/$(APP_NAME)-$(VERSION)-darwin-arm64.zip # $(BUILD_DIR)/$(APP_NAME)-$(VERSION)-darwin-arm64.zip
# Build and compress all release artifacts # Build and compress all release artifacts
release: build-linux build-darwin compress # release: build-linux build-darwin compress
release: build-linux compress
@echo "Release artifacts created:" @echo "Release artifacts created:"
@ls -la $(BUILD_DIR)/*.tar.gz $(BUILD_DIR)/*.zip 2>/dev/null || echo "No compressed artifacts found" @ls -la $(BUILD_DIR)/*.tar.gz $(BUILD_DIR)/*.zip 2>/dev/null || echo "No compressed artifacts found"

View File

@@ -3,10 +3,6 @@
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.
> [!CAUTION]
> The Zig community is quite anti-AI. Please read the [AI Disclaimer](#ai-disclaimer)
> before wasting your time.
`envr` is a binary application 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`.
@@ -17,7 +13,7 @@ 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) encryption.
- 🔄 **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
@@ -41,12 +37,13 @@ repositories.
## Installation ## Installation
### With Go ### With Odin
If you already have `go` installed: If you already have `odin` installed:
```bash ```bash
go install github.com/sbrow/envr # You'll need libsodium and sqlite
odin build -o:speed
envr init envr init
``` ```
@@ -108,18 +105,18 @@ The configuration file is created during initialization:
## 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` preserves inodes when updating the database, so you can safely hardlink
`~/.envr/data.age` into your [GNU Stow](https://www.gnu.org/software/stow/), `~/.envr/data.envr` into your [GNU Stow](https://www.gnu.org/software/stow/),
[Home Manager](https://github.com/nix-community/home-manager), or [Home Manager](https://github.com/nix-community/home-manager), or
[NixOS](https://nixos.wiki/wiki/flakes) repository. [NixOS](https://nixos.wiki/wiki/flakes) repository.
> [!CAUTION] > [!CAUTION]
> For **maximum security**, only save your `data.age` file to a local > For **maximum security**, only save your `data.envr` file to a local
(i.e. non-cloud) git server that **you personally control**. (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. > I take no responsibility if you push all your secrets to a public GitHub repo.
@@ -136,13 +133,3 @@ This project is licensed under the [MIT License](./LICENSE).
For issues, feature requests, or questions, please For issues, feature requests, or questions, please
[open an issue](https://github.com/sbrow/envr/issues). [open an issue](https://github.com/sbrow/envr/issues).
## AI Disclaimer
Unless noted here, you can be assured that I have personally written and reviewed
every line of code in this software.
- Many compiler errors that couldn't be solved with a quick google search were
solved by passing errors to AI and transcribing the suggestions.
- The "Pre-Zig" version of this readme was written by AI and then edited by me.
- The Go code was mostly written using opencode, and manually tested by me.

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

110
TEST_PLAN.md Normal file
View File

@@ -0,0 +1,110 @@
# Test Coverage Plan
## Current State
- 63 tests, all passing (added 3 `render_table` tests)
- Strong coverage: crypto (100%), ssh (80%), scan, features
- `render_table` now takes `io.Writer` (Tier 1 item 1 done)
- Misleading test files: `cmd_check_test`, `cmd_list_test`, `cmd_nushell_completion_test` don't test their namesake procs
- Biggest gap: `db.odin` (15/21 procs untested), all `cmd_*` handlers untested, `parse_args` untested
## Tier 1 — Easy wins (pure functions, minimal setup)
### 1. `render_table` (table.odin)
- Follow existing `render_json_rows` test pattern
- Test cases: normal data (verify box-drawing chars, column alignment), empty rows, wide unicode, single column
- Assert against `strings.Builder` output
### 2. `parse_args` (cli.odin) — BLOCKED: needs refactor
- Reads `os.args` directly and calls `print_usage()`/`print_command_help()` as side effects
- Cannot test without either accepting `[]string` param or extracting output
- Minimal refactor: `parse_args(args: []string)` — caller passes `os.args`, tests pass synthetic slices
- Return values (`ok`, `cmd.name`, `cmd.flags`, `cmd.bool_set`) are the interesting part to assert
- Test cases: bare command, `--flag value`, `-f value`, positional args, `--help`/`-h`, unknown command, no args, mixed flags + positionals
### 3. `is_encrypted_key` (ssh.odin)
- Test cases: encrypted key (returns true), unencrypted key (returns false), RSA key, malformed key
- Fills last gap in ssh.odin
## Tier 2 — High value, medium effort (fixtures exist)
### 4. `db.odin` CRUD layer
Largest gap in the project. Infrastructure already in `db_integration_test.odin` (`fixture_key`, `fixture_db_path`, in-memory DB setup).
Procs to test:
- `db_open` / `db_close` — open in-memory DB, verify handle valid
- `db_insert` — insert a row, verify it persists
- `db_fetch` — fetch existing row, fetch missing row (returns false)
- `db_delete` — delete existing row (returns true), delete missing row (returns false)
- `db_list` — list multiple rows, empty DB
- `db_vacuum_to_file` — vacuum to temp file, verify file exists and is non-empty
Test pattern: create in-memory DB via `db_open`, insert fixture rows, query and assert, `defer db_close`.
### 5. `load_config` / `save_config` (config.odin)
- `save_config`: write a `Config` to temp dir, verify file exists and contents are valid JSON
- `load_config`: read back a config written by `save_config`, round-trip assert
- `load_config` error case: missing file returns error
- Need a temp dir fixture (pattern exists in `scan_test.odin`)
## Tier 3 — Command handlers (need DB + filesystem fixtures)
### 6. `cmd_version` (cmd_version.odin)
- Test default output (prints VERSION)
- Test `--long`/`-l` flag output
- Capture stdout, assert content
### 7. `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 `[]`)
### 8. `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
### 9. `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
### 10. `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
## Tier 4 — Hard to test (interactive / external deps)
### 11. `cmd_deps` (cmd_deps.odin)
- Needs `git` and/or `fd` in PATH
- Test TTY and non-TTY paths
- Skip if dependencies not available (with `#assert` like TODO 28 suggests)
### 12. `cmd_scan` (cmd_scan.odin)
- Needs `fd` installed
- 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
### 13. `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`
### 14. `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
### 15. `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
- All command handler tests will need stdout capture. Consider extracting a helper or using `io.Writer` injection.
- DB integration tests should use in-memory SQLite (`:memory:`) where possible.
- Temp dir fixtures should follow the pattern in `scan_test.odin`.
- External dependency tests (`fd`, `git`) should use `#assert` to ensure the dependency is present rather than silently skipping (TODO 28).

View File

@@ -4,14 +4,10 @@ Note: These todos can wait until all the subcommands have been ported.
## HIGH ## HIGH
1. [x] **table.odin:74-89** — Hand-rolled JSON output doesn't escape `"`, `\`, newlines. Reimplements `json.marshal` which is already imported in `cmd_list.odin`. Replace with `json.marshal`.
2. **db.odin:380-383, 405, 446**`sqlite.bind_text` return values overwritten but never checked. A failed bind means `sqlite.step` operates on unbound params. 2. **db.odin:380-383, 405, 446**`sqlite.bind_text` return values overwritten but never checked. A failed bind means `sqlite.step` operates on unbound params.
3. **config.odin:52-54**`os.user_home_dir` error silently ignored. If it fails, `home` is `""` and all paths become relative (`".envr"` instead of `"~/.envr"`). 3. **config.odin:52-54**`os.user_home_dir` error silently ignored. If it fails, `home` is `""` and all paths become relative (`".envr"` instead of `"~/.envr"`).
30. **cmd_sync.odin:46-50, 64-68** — Double `db_insert` when `BackedUp`: first insert on line 48, then `db_update_required` is also true for `BackedUp` so second insert runs on line 65. Redundant and wasteful.
## MEDIUM ## MEDIUM
4. **db.odin:29-35**`make_temp_path` never calls `strings.builder_destroy`. Leaks builder buffer every call. 4. **db.odin:29-35**`make_temp_path` never calls `strings.builder_destroy`. Leaks builder buffer every call.
@@ -36,9 +32,9 @@ Note: These todos can wait until all the subcommands have been ported.
35. **prompt.odin:124**`make([dynamic]bool, len(options))` creates N zero-initialized elements. Works because `false` is the default, but same footgun as original issue 1. Should be `make([dynamic]bool, 0, len(options))`. 35. **prompt.odin:124**`make([dynamic]bool, len(options))` creates N zero-initialized elements. Works because `false` is the default, but same footgun as original issue 1. Should be `make([dynamic]bool, 0, len(options))`.
## LOW 39. Lots of memory leaks to fix.
14. [x] **db.odin:338-341** — Unnecessary `strings.clone` before `filepath.dir` (which already returns a slice into the input). ## LOW
15. **db.odin:115**`json.unmarshal_string` error not checked. Malformed JSON silently produces empty/partial data. 15. **db.odin:115**`json.unmarshal_string` error not checked. Malformed JSON silently produces empty/partial data.
@@ -54,8 +50,6 @@ Note: These todos can wait until all the subcommands have been ported.
21. Check for prealloc opportunities. i.e. `make([dynamic]string)` -> `make([dynamic]string, 5)`. 21. Check for prealloc opportunities. i.e. `make([dynamic]string)` -> `make([dynamic]string, 5)`.
22. Replace is_tty with terminal.is_terminal
23. Add a text filter to the multi_select. 23. Add a text filter to the multi_select.
24. Create backup / fallback fd. 24. Create backup / fallback fd.
@@ -67,3 +61,46 @@ Note: These todos can wait until all the subcommands have been ported.
27. version --long Odin only prints version; Go also prints commit hash and build date 27. version --long Odin only prints version; Go also prints commit hash and build date
28. 2 scan tests silently skip Low When fd isn't installed, tests pass without actually testing anything. These should use #assert to be sure that fd is in path. 28. 2 scan tests silently skip Low When fd isn't installed, tests pass without actually testing anything. These should use #assert to be sure that fd is in path.
38. Try to do all encryption / decryption in memory - only read / write encrypted data to disk.
40. use a buffered writer where possible (mem.DEFAULT_PAGE_SIZE)
## Double-check AI output
- [ ] cli.odin
- [ ] cli_test.odin
- [ ] cmd_backup.odin
- [ ] cmd_check.odin
- [ ] cmd_check_test.odin
- [ ] cmd_deps.odin
- [ ] cmd_edit_config.odin
- [ ] cmd_init.odin
- [ ] cmd_list.odin
- [ ] cmd_list_test.odin
- [ ] cmd_nushell_completion.odin
- [ ] cmd_nushell_completion_test.odin
- [ ] cmd_remove.odin
- [ ] cmd_restore.odin
- [ ] cmd_scan.odin
- [ ] 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
- [ ] features.odin
- [ ] features_test.odin
- [ ] main.odin
- [ ] prompt.odin
- [ ] scan.odin
- [ ] scan_test.odin
- [ ] sodium.odin
- [ ] sqlite/sqlite.odin
- [ ] ssh.odin
- [ ] ssh_test.odin
- [ ] table.odin
- [ ] table_test.odin

View File

@@ -1,267 +0,0 @@
package app
import (
"encoding/json"
"errors"
"fmt"
"os"
"os/exec"
"path"
"path/filepath"
"strings"
"filippo.io/age"
"filippo.io/age/agessh"
)
type Config struct {
Keys []SshKeyPair `json:"keys"`
ScanConfig scanConfig `json:"scan"`
}
// Used by age to encrypt and decrypt the database.
type SshKeyPair struct {
Private string `json:"private"` // Path to the private key file
Public string `json:"public"` // Path to the public key file
}
type scanConfig struct {
// TODO: Support multiple matchers
Matcher string `json:"matcher"`
Exclude []string `json:"exclude"`
Include []string `json:"include"`
}
// Create a fresh config with sensible defaults.
func NewConfig(privateKeyPaths []string) Config {
var keys = []SshKeyPair{}
for _, priv := range privateKeyPaths {
var key = SshKeyPair{
Private: priv,
Public: priv + ".pub",
}
keys = append(keys, key)
}
return Config{
Keys: keys,
ScanConfig: scanConfig{
Matcher: "\\.env",
Exclude: []string{
"*\\.envrc",
"\\.local",
"node_modules",
"vendor",
},
Include: []string{"~"},
},
}
}
// Read the Config from disk.
func LoadConfig() (*Config, error) {
homeDir, err := os.UserHomeDir()
if err != nil {
return nil, err
}
configPath := filepath.Join(homeDir, ".envr", "config.json")
data, err := os.ReadFile(configPath)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
return nil, fmt.Errorf("No config file found. Please run `envr init` to generate one.")
} else {
return nil, err
}
}
var config Config
if err := json.Unmarshal(data, &config); err != nil {
return nil, err
}
return &config, nil
}
// Write the Config to disk.
func (c *Config) Save() error {
// Create the ~/.envr directory
homeDir, err := os.UserHomeDir()
if err != nil {
return err
}
configDir := filepath.Join(homeDir, ".envr")
if err := os.MkdirAll(configDir, 0755); err != nil {
return err
}
configPath := filepath.Join(configDir, "config.json")
// Check if file exists and is not empty
if info, err := os.Stat(configPath); err == nil {
if info.Size() > 0 {
return os.ErrExist
}
}
data, err := json.MarshalIndent(c, "", " ")
if err != nil {
return err
}
return os.WriteFile(configPath, data, 0644)
}
// buildFdArgs builds the fd command arguments with multiple exclude patterns
func (c Config) buildFdArgs(searchPath string, includeIgnored bool) []string {
args := []string{"-a", c.ScanConfig.Matcher}
// Add exclude patterns
for _, exclude := range c.ScanConfig.Exclude {
args = append(args, "-E", exclude)
}
if includeIgnored {
args = append(args, "-HI")
} else {
args = append(args, "-H")
}
args = append(args, searchPath)
return args
}
// Use fd to find all ignored .env files that match the config's parameters
func (c Config) scan() (paths []string, err error) {
searchPaths, err := c.searchPaths()
if err != nil {
return []string{}, err
}
for _, searchPath := range searchPaths {
// Find all files (including ignored ones)
fmt.Printf("Searching for all files in \"%s\"...\n", searchPath)
allCmd := exec.Command("fd", c.buildFdArgs(searchPath, true)...)
allOutput, err := allCmd.Output()
if err != nil {
return paths, err
}
allFiles := strings.Split(strings.TrimSpace(string(allOutput)), "\n")
if len(allFiles) == 1 && allFiles[0] == "" {
allFiles = []string{}
}
// Find unignored files
fmt.Printf("Search for unignored fies in \"%s\"...\n", searchPath)
unignoredCmd := exec.Command("fd", c.buildFdArgs(searchPath, false)...)
unignoredOutput, err := unignoredCmd.Output()
if err != nil {
return []string{}, err
}
unignoredFiles := strings.Split(strings.TrimSpace(string(unignoredOutput)), "\n")
if len(unignoredFiles) == 1 && unignoredFiles[0] == "" {
unignoredFiles = []string{}
}
// Create a map for faster lookup
unignoredMap := make(map[string]bool)
for _, file := range unignoredFiles {
unignoredMap[file] = true
}
// Filter to get only ignored files
var ignoredFiles []string
for _, file := range allFiles {
if !unignoredMap[file] {
ignoredFiles = append(ignoredFiles, file)
}
}
paths = append(paths, ignoredFiles...)
}
return paths, nil
}
func (c Config) searchPaths() (paths []string, err error) {
homeDir, err := os.UserHomeDir()
if err != nil {
return paths, err
}
includes := c.ScanConfig.Include
for _, include := range includes {
path := strings.Replace(include, "~", homeDir, 1)
absPath, err := filepath.Abs(path)
if err != nil {
return paths, err
}
paths = append(paths, absPath)
}
return paths, nil
}
func (s SshKeyPair) identity() (age.Identity, error) {
sshKey, err := os.ReadFile(s.Private)
if err != nil {
return nil, fmt.Errorf("failed to read SSH key: %w", err)
}
id, err := agessh.ParseIdentity(sshKey)
if err != nil {
return nil, fmt.Errorf("failed to parse SSH identity: %w", err)
}
return id, nil
}
func (s SshKeyPair) recipient() (age.Recipient, error) {
sshKey, err := os.ReadFile(s.Public)
if err != nil {
return nil, fmt.Errorf("failed to read SSH key: %w", err)
}
id, err := agessh.ParseRecipient(string(sshKey))
if err != nil {
return nil, fmt.Errorf("failed to parse SSH identity: %w", err)
}
return id, nil
}
// Use fd to find all git roots in the config's search paths
func (c Config) findGitRoots() (paths []string, err error) {
searchPaths, err := c.searchPaths()
if err != nil {
return []string{}, err
}
for _, searchPath := range searchPaths {
allCmd := exec.Command("fd", "-H", "-t", "d", "^\\.git$", searchPath)
allOutput, err := allCmd.Output()
if err != nil {
return paths, err
}
allFiles := strings.Split(strings.TrimSpace(string(allOutput)), "\n")
if len(allFiles) == 1 && allFiles[0] == "" {
allFiles = []string{}
}
for i, file := range allFiles {
allFiles[i] = path.Dir(path.Clean(file))
}
paths = append(paths, allFiles...)
}
return paths, nil
}

421
app/db.go
View File

@@ -1,421 +0,0 @@
package app
// TODO: app/db.go should be reviewed.
import (
"database/sql"
"encoding/json"
"fmt"
"io"
"os"
"path/filepath"
"slices"
"filippo.io/age"
_ "modernc.org/sqlite"
)
type Db struct {
db *sql.DB
cfg Config
features *AvailableFeatures
// If true, the database will be saved to disk before closing
changed bool
}
func Open() (*Db, error) {
cfg, err := LoadConfig()
if err != nil {
return nil, err
}
if _, err := os.Stat("/home/spencer/.envr/data.age"); err != nil {
// Create a new DB
db, err := newDb()
return &Db{db, *cfg, nil, true}, err
} else {
// Open the existing DB
tmpFile, err := os.CreateTemp("", "envr-*.db")
if err != nil {
return nil, fmt.Errorf("failed to create temp file: %w", err)
}
defer tmpFile.Close()
defer os.Remove(tmpFile.Name())
err = decryptDb(tmpFile.Name(), (*cfg).Keys)
if err != nil {
return nil, fmt.Errorf("failed to decrypt database: %w", err)
}
memDb, err := newDb()
if err != nil {
return nil, fmt.Errorf("failed to open temp database: %w", err)
}
restoreDB(tmpFile.Name(), memDb)
return &Db{memDb, *cfg, nil, false}, nil
}
}
// Creates the database for the first time
func newDb() (*sql.DB, error) {
db, err := sql.Open("sqlite", ":memory:")
if err != nil {
return nil, err
} else {
_, err := db.Exec(`create table envr_env_files (
path text primary key not null
, remotes text -- JSON
, sha256 text not null
, contents text not null
);`)
if err != nil {
return nil, err
} else {
return db, err
}
}
}
// Decrypt the database from the age file into a temp sqlite file.
func decryptDb(tmpFilePath string, keys []SshKeyPair) error {
homeDir, err := os.UserHomeDir()
if err != nil {
return fmt.Errorf("failed to get user home directory: %w", err)
}
tmpFile, err := os.OpenFile(tmpFilePath, os.O_WRONLY, 0)
if err != nil {
return fmt.Errorf("failed to open temp file: %w", err)
}
defer tmpFile.Close()
ageFilePath := filepath.Join(homeDir, ".envr", "data.age")
ageFile, err := os.Open(ageFilePath)
if err != nil {
return fmt.Errorf("failed to open age file: %w", err)
}
defer ageFile.Close()
identities := make([]age.Identity, 0, len(keys))
for _, key := range keys {
id, err := key.identity()
if err != nil {
return err
}
identities = append(identities, id)
}
reader, err := age.Decrypt(ageFile, identities[:]...)
if err != nil {
return fmt.Errorf("failed to decrypt age file: %w", err)
}
_, err = io.Copy(tmpFile, reader)
if err != nil {
return fmt.Errorf("failed to copy decrypted content: %w", err)
}
return nil
}
// Restore the database from a file into memory
func restoreDB(path string, destDB *sql.DB) error {
// Attach the source database
_, err := destDB.Exec("ATTACH DATABASE ? AS source", path)
if err != nil {
return fmt.Errorf("failed to attach database: %w", err)
}
defer destDB.Exec("DETACH DATABASE source")
// Copy data from source to destination
_, err = destDB.Exec("INSERT INTO main.envr_env_files SELECT * FROM source.envr_env_files")
if err != nil {
return fmt.Errorf("failed to copy data: %w", err)
}
return nil
}
// Returns all the EnvFiles present in the database.
func (db *Db) List() (results []EnvFile, err error) {
rows, err := db.db.Query("select path, remotes, sha256, contents from envr_env_files")
if err != nil {
return nil, err
}
defer rows.Close()
for rows.Next() {
var envFile EnvFile
var remotesJson []byte
err := rows.Scan(&envFile.Path, &remotesJson, &envFile.Sha256, &envFile.contents)
if err != nil {
return nil, err
}
// Populate Dir from Path
envFile.Dir = filepath.Dir(envFile.Path)
if err := json.Unmarshal(remotesJson, &envFile.Remotes); err != nil {
return nil, err
}
results = append(results, envFile)
}
if err = rows.Err(); err != nil {
return nil, err
}
return results, nil
}
func (db *Db) Close() error {
defer db.db.Close()
if db.changed {
// Create tmp file
tmpFile, err := os.CreateTemp("", "envr-*.db")
if err != nil {
return fmt.Errorf("failed to create temp file: %w", err)
}
defer tmpFile.Close()
defer os.Remove(tmpFile.Name())
if err := backupDb(db.db, tmpFile.Name()); err != nil {
return err
}
if err := encryptDb(tmpFile.Name(), db.cfg.Keys); err != nil {
return err
}
db.changed = false
}
return nil
}
// Save the in-memory database to a tmp file.
func backupDb(memDb *sql.DB, tmpFilePath string) error {
_, err := memDb.Exec("VACUUM INTO ?", tmpFilePath)
if err != nil {
return fmt.Errorf("failed to vacuum database to file: %w", err)
}
return nil
}
// Encrypt the database from the temp sqlite file into an age file.
func encryptDb(tmpFilePath string, keys []SshKeyPair) error {
homeDir, err := os.UserHomeDir()
if err != nil {
return fmt.Errorf("failed to get user home directory: %w", err)
}
ageFilePath := filepath.Join(homeDir, ".envr", "data.age")
// Ensure .envr directory exists
err = os.MkdirAll(filepath.Dir(ageFilePath), 0755)
if err != nil {
return fmt.Errorf("failed to create .envr directory: %w", err)
}
// Open temp file for reading
tmpFile, err := os.Open(tmpFilePath)
if err != nil {
return fmt.Errorf("failed to open temp file: %w", err)
}
defer tmpFile.Close()
// Open/create age file for writing (this preserves hardlinks)
ageFile, err := os.OpenFile(ageFilePath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0600)
if err != nil {
return fmt.Errorf("failed to open age file: %w", err)
}
defer ageFile.Close()
recipients := make([]age.Recipient, 0, len(keys))
for _, key := range keys {
recipient, err := key.recipient()
if err != nil {
return err
}
recipients = append(recipients, recipient)
}
writer, err := age.Encrypt(ageFile, recipients...)
if err != nil {
return fmt.Errorf("failed to create age writer: %w", err)
}
_, err = io.Copy(writer, tmpFile)
if err != nil {
return fmt.Errorf("failed to encrypt and write data: %w", err)
}
err = writer.Close()
if err != nil {
return fmt.Errorf("failed to close age writer: %w", err)
}
return nil
}
func (db *Db) Insert(file EnvFile) error {
// Marshal remotes to JSON
remotesJSON, err := json.Marshal(file.Remotes)
if err != nil {
return fmt.Errorf("failed to marshal remotes: %w", err)
}
// Insert into database
_, err = db.db.Exec(`
INSERT OR REPLACE INTO envr_env_files (path, remotes, sha256, contents)
VALUES (?, ?, ?, ?)
`, file.Path, string(remotesJSON), file.Sha256, file.contents)
if err != nil {
return fmt.Errorf("failed to insert env file: %w", err)
}
db.changed = true
return nil
}
// Select a single EnvFile from the database.
func (db *Db) Fetch(path string) (envFile EnvFile, err error) {
var remotesJSON string
row := db.db.QueryRow("SELECT path, remotes, sha256, contents FROM envr_env_files WHERE path = ?", path)
err = row.Scan(&envFile.Path, &remotesJSON, &envFile.Sha256, &envFile.contents)
if err != nil {
return EnvFile{}, fmt.Errorf("failed to fetch env file: %w", err)
}
// Populate Dir from Path
envFile.Dir = filepath.Dir(envFile.Path)
if err = json.Unmarshal([]byte(remotesJSON), &envFile.Remotes); err != nil {
return EnvFile{}, fmt.Errorf("failed to unmarshal remotes: %w", err)
}
return envFile, nil
}
// Removes a file from the database, if present.
func (db *Db) Delete(path string) error {
result, err := db.db.Exec("DELETE FROM envr_env_files WHERE path = ?", path)
if err != nil {
return fmt.Errorf("failed to delete env file: %w", err)
}
rowsAffected, err := result.RowsAffected()
if err != nil {
return fmt.Errorf("failed to get rows affected: %w", err)
}
if rowsAffected == 0 {
return fmt.Errorf("no file found with path: %s", path)
}
db.changed = true
return nil
}
// Finds .env files in the filesystem that aren't present in the database.
// path overrides the already configured
func (db *Db) Scan(paths []string) ([]string, error) {
cfg := db.cfg
if paths != nil {
cfg.ScanConfig.Include = paths
}
all_paths, err := cfg.scan()
if err != nil {
return []string{}, err
}
untracked_paths := make([]string, 0, len(all_paths)/2)
env_files, err := db.List()
if err != nil {
return untracked_paths, err
}
for _, path := range all_paths {
backed_up := slices.ContainsFunc(env_files, func(e EnvFile) bool {
return e.Path == path
})
if backed_up {
continue
} else {
untracked_paths = append(untracked_paths, path)
}
}
return untracked_paths, nil
}
// Determine the available features on the installed system.
func (db *Db) Features() AvailableFeatures {
if db.features == nil {
feats := checkFeatures()
db.features = &feats
}
return *db.features
}
// Returns nil if [Db.Scan] is safe to use, null otherwise.
func (db *Db) CanScan() error {
if db.Features()&Fd == 0 {
return fmt.Errorf(
"please install fd to use the scan function (https://github.com/sharkdp/fd)",
)
} else {
return nil
}
}
// If true, [Db.Insert] should be called on the [EnvFile] that generated
// the given result
func (db Db) UpdateRequired(status EnvFileSyncResult) bool {
return status&(BackedUp|DirUpdated) != 0
}
func (db *Db) Sync(file *EnvFile) (result EnvFileSyncResult, err error) {
// TODO: This results in findMovedDirs being called multiple times.
return file.sync(TrustFilesystem, db)
}
// Looks for git directories that share one or more git remotes with
// the given file.
func (db Db) findMovedDirs(file *EnvFile) (movedDirs []string, err error) {
if err = db.Features().validateFeatures(Fd, Git); err != nil {
return movedDirs, err
}
gitRoots, err := db.cfg.findGitRoots()
if err != nil {
return movedDirs, err
} else {
for _, dir := range gitRoots {
if file.sharesRemote(getGitRemotes(dir)) {
movedDirs = append(movedDirs, dir)
}
}
return movedDirs, nil
}
}

View File

@@ -1,244 +0,0 @@
package app
import (
"crypto/sha256"
"errors"
"fmt"
"os"
"os/exec"
"path"
"path/filepath"
"strings"
)
type EnvFile struct {
// TODO: Should use FileName in the struct and derive from the path.
Path string
// Dir is derived from Path, and is not stored in the database.
Dir string
Remotes []string // []string
Sha256 string
contents string
}
// The result returned by [EnvFile.Sync]
type EnvFileSyncResult int
const (
// The filesystem contents matches the struct
// no further action is required.
Noop EnvFileSyncResult = 0
// The directory changed, but the file contents matched.
// The database must be updated.
DirUpdated EnvFileSyncResult = 1
// The filesystem has been restored to match the struct
// no further action is required.
Restored EnvFileSyncResult = 1 << 1
// The filesystem has been restored to match the struct.
// The directory changed, so the database must be updated
RestoredAndDirUpdated EnvFileSyncResult = Restored | DirUpdated
// The struct has been updated from the filesystem
// and should be updated in the database.
BackedUp EnvFileSyncResult = 1 << 2
Error EnvFileSyncResult = 1 << 3
)
// Determines the source of truth when calling [EnvFile.Sync] or [EnvFile.Restore]
type syncDirection int
const (
TrustDatabase syncDirection = iota
TrustFilesystem
)
func NewEnvFile(path string) EnvFile {
// Get absolute path and directory
absPath, err := filepath.Abs(path)
if err != nil {
panic(fmt.Errorf("failed to get absolute path: %w", err))
}
dir := filepath.Dir(absPath)
// Get git remotes
remotes := getGitRemotes(dir)
// Read the file contents
contents, err := os.ReadFile(path)
if err != nil {
panic(fmt.Errorf("failed to read file %s: %w", path, err))
}
// Calculate SHA256 hash
hash := sha256.Sum256(contents)
sha256Hash := fmt.Sprintf("%x", hash)
return EnvFile{
Path: absPath,
Dir: dir,
Remotes: remotes,
Sha256: sha256Hash,
contents: string(contents),
}
}
func getGitRemotes(dir string) []string {
// TODO: Check for Git flag and change behaviour if unset.
cmd := exec.Command("git", "remote", "-v")
cmd.Dir = dir
output, err := cmd.Output()
if err != nil {
// Not a git repository or git command failed
return []string{}
}
lines := strings.Split(strings.TrimSpace(string(output)), "\n")
remoteSet := make(map[string]bool)
for _, line := range lines {
if line == "" {
continue
}
parts := strings.Fields(line)
if len(parts) >= 2 {
remoteSet[parts[1]] = true
}
}
remotes := make([]string, 0, len(remoteSet))
for remote := range remoteSet {
remotes = append(remotes, remote)
}
return remotes
}
// Reconcile the state of the database with the state of the filesystem, using
// dir to determine which side to use a the source of truth.
func (f *EnvFile) sync(dir syncDirection, db *Db) (result EnvFileSyncResult, err error) {
if result != Noop {
panic("Invalid state")
}
if _, err := os.Stat(f.Dir); err != nil {
// Directory doesn't exist
var movedDirs []string
if db != nil {
movedDirs, err = db.findMovedDirs(f)
}
if err != nil {
return Error, err
} else {
switch len(movedDirs) {
case 0:
return Error, fmt.Errorf("directory missing")
case 1:
f.updateDir(movedDirs[0])
result |= DirUpdated
default:
return Error, fmt.Errorf("multiple directories found")
}
}
}
if _, err := os.Stat(f.Path); err != nil {
if errors.Is(err, os.ErrNotExist) {
if err := os.WriteFile(f.Path, []byte(f.contents), 0644); err != nil {
return Error, fmt.Errorf("failed to write file: %w", err)
}
return result | Restored, nil
} else {
return Error, err
}
} else {
// File exists, check its hash
contents, err := os.ReadFile(f.Path)
if err != nil {
return Error, fmt.Errorf("failed to read file for SHA comparison: %w", err)
}
hash := sha256.Sum256(contents)
currentSha := fmt.Sprintf("%x", hash)
// Compare the hashes
if currentSha == f.Sha256 {
// No op, or DirUpdated
return result, nil
} else {
switch dir {
case TrustDatabase:
if err := os.WriteFile(f.Path, []byte(f.contents), 0644); err != nil {
return Error, fmt.Errorf("failed to write file: %w", err)
}
return result | Restored, nil
case TrustFilesystem:
// Overwrite the database
if err = f.Backup(); err != nil {
return Error, err
} else {
return BackedUp, nil
}
default:
panic("unknown sync direction")
}
}
}
}
func (f *EnvFile) sharesRemote(remotes []string) bool {
rMap := make(map[string]bool)
for _, remote := range f.Remotes {
rMap[remote] = true
}
for _, remote := range remotes {
if rMap[remote] {
return true
}
}
return false
}
func (f *EnvFile) updateDir(newDir string) {
f.Dir = newDir
f.Path = path.Join(newDir, path.Base(f.Path))
f.Remotes = getGitRemotes(newDir)
}
// Try to reconcile the EnvFile with the filesystem.
//
// If Updated is returned, [Db.Insert] should be called on file.
func (file *EnvFile) Sync() (result EnvFileSyncResult, err error) {
return file.sync(TrustFilesystem, nil)
}
// Install the file into the file system. If the file already exists,
// it will be overwritten.
func (file EnvFile) Restore() error {
_, err := file.sync(TrustDatabase, nil)
return err
}
// Update the EnvFile using the file system.
func (file *EnvFile) Backup() error {
// Read the contents of the file
contents, err := os.ReadFile(file.Path)
if err != nil {
return fmt.Errorf("failed to read file %s: %w", file.Path, err)
}
// Update file.contents to match
file.contents = string(contents)
// Update file.sha256
hash := sha256.Sum256(contents)
file.Sha256 = fmt.Sprintf("%x", hash)
return nil
}

View File

@@ -1,60 +0,0 @@
package app
import (
"fmt"
"os/exec"
)
type MissingFeatureError struct {
feature AvailableFeatures
}
func (m *MissingFeatureError) Error() string {
return fmt.Sprintf("Missing \"%s\" feature", m.feature)
}
// TODO: Features should really be renamed to Binaries
// Represents which binaries are present in $PATH.
// Used to fail safely when required features are unavailable
type AvailableFeatures int
const (
Git AvailableFeatures = 1
// fd
Fd AvailableFeatures = 2
// All features are present
All AvailableFeatures = Git | Fd
)
// Checks for available features.
func checkFeatures() (feats AvailableFeatures) {
// Check for git binary
if _, err := exec.LookPath("git"); err == nil {
feats |= Git
}
// Check for fd binary
if _, err := exec.LookPath("fd"); err == nil {
feats |= Fd
}
return feats
}
// Returns a MissingFeature error if the given features aren't present.
func (a AvailableFeatures) validateFeatures(features ...AvailableFeatures) error {
var missing AvailableFeatures
for _, feat := range features {
if a&feat == 0 {
missing |= feat
}
}
if missing == 0 {
return nil
} else {
return &MissingFeatureError{missing}
}
}

179
build.zig
View File

@@ -1,179 +0,0 @@
const std = @import("std");
// Although this function looks imperative, it does not perform the build
// directly and instead it mutates the build graph (`b`) that will be then
// executed by an external runner. The functions in `std.Build` implement a DSL
// for defining build steps and express dependencies between them, allowing the
// build runner to parallelize the build automatically (and the cache system to
// know when a step doesn't need to be re-run).
pub fn build(b: *std.Build) void {
// Standard target options allow the person running `zig build` to choose
// what target to build for. Here we do not override the defaults, which
// means any target is allowed, and the default is native. Other options
// for restricting supported target set are available.
const target = b.standardTargetOptions(.{});
// Standard optimization options allow the person running `zig build` to select
// between Debug, ReleaseSafe, ReleaseFast, and ReleaseSmall. Here we do not
// set a preferred release mode, allowing the user to decide how to optimize.
const optimize = b.standardOptimizeOption(.{});
// It's also possible to define more custom flags to toggle optional features
// of this build script using `b.option()`. All defined flags (including
// target and optimize options) will be listed when running `zig build --help`
// in this directory.
const comma = b.addModule("comma", .{
.root_source_file = b.path("src/comma.zig"),
.target = target,
});
const sqlite = b.dependency("sqlite", .{
.target = target,
.optimize = optimize,
});
// This creates a module, which represents a collection of source files alongside
// some compilation options, such as optimization mode and linked system libraries.
// Zig modules are the preferred way of making Zig code available to consumers.
// addModule defines a module that we intend to make available for importing
// to our consumers. We must give it a name because a Zig package can expose
// multiple modules and consumers will need to be able to specify which
// module they want to access.
const mod = b.addModule("envr", .{
// The root source file is the "entry point" of this module. Users of
// this module will only be able to access public declarations contained
// in this file, which means that if you have declarations that you
// intend to expose to consumers that were defined in other files part
// of this module, you will have to make sure to re-export them from
// the root file.
.root_source_file = b.path("src/root.zig"),
// Later on we'll use this module as the root module of a test executable
// which requires us to specify a target.
.target = target,
.imports = &.{
.{ .name = "comma", .module = comma },
},
});
mod.addImport("sqlite", sqlite.module("sqlite"));
// Here we define an executable. An executable needs to have a root module
// which needs to expose a `main` function. While we could add a main function
// to the module defined above, it's sometimes preferable to split business
// logic and the CLI into two separate modules.
//
// If your goal is to create a Zig library for others to use, consider if
// it might benefit from also exposing a CLI tool. A parser library for a
// data serialization format could also bundle a CLI syntax checker, for example.
//
// If instead your goal is to create an executable, consider if users might
// be interested in also being able to embed the core functionality of your
// program in their own executable in order to avoid the overhead involved in
// subprocessing your CLI tool.
//
// If neither case applies to you, feel free to delete the declaration you
// don't need and to put everything under a single module.
const exe = b.addExecutable(.{
.name = "envr",
.root_module = b.createModule(.{
// b.createModule defines a new module just like b.addModule but,
// unlike b.addModule, it does not expose the module to consumers of
// this package, which is why in this case we don't have to give it a name.
.root_source_file = b.path("src/main.zig"),
// Target and optimization levels must be explicitly wired in when
// defining an executable or library (in the root module), and you
// can also hardcode a specific target for an executable or library
// definition if desireable (e.g. firmware for embedded devices).
.target = target,
.optimize = optimize,
// List of modules available for import in source files part of the
// root module.
.imports = &.{
// Here "envr" is the name you will use in your source code to
// import this module (e.g. `@import("envr")`). The name is
// repeated because you are allowed to rename your imports, which
// can be extremely useful in case of collisions (which can happen
// importing modules from different packages).
.{ .name = "comma", .module = comma },
.{ .name = "envr", .module = mod },
},
}),
});
const version = b.option([]const u8, "version", "application version string") orelse "dev";
const options = b.addOptions();
options.addOption([]const u8, "version", version);
exe.root_module.addOptions("config", options);
// This declares intent for the executable to be installed into the
// install prefix when running `zig build` (i.e. when executing the default
// step). By default the install prefix is `zig-out/` but can be overridden
// by passing `--prefix` or `-p`.
b.installArtifact(exe);
// This creates a top level step. Top level steps have a name and can be
// invoked by name when running `zig build` (e.g. `zig build run`).
// This will evaluate the `run` step rather than the default step.
// For a top level step to actually do something, it must depend on other
// steps (e.g. a Run step, as we will see in a moment).
const run_step = b.step("run", "Run the app");
// This creates a RunArtifact step in the build graph. A RunArtifact step
// invokes an executable compiled by Zig. Steps will only be executed by the
// runner if invoked directly by the user (in the case of top level steps)
// or if another step depends on it, so it's up to you to define when and
// how this Run step will be executed. In our case we want to run it when
// the user runs `zig build run`, so we create a dependency link.
const run_cmd = b.addRunArtifact(exe);
run_step.dependOn(&run_cmd.step);
// By making the run step depend on the default step, it will be run from the
// installation directory rather than directly from within the cache directory.
run_cmd.step.dependOn(b.getInstallStep());
// This allows the user to pass arguments to the application in the build
// command itself, like this: `zig build run -- arg1 arg2 etc`
if (b.args) |args| {
run_cmd.addArgs(args);
}
// Creates an executable that will run `test` blocks from the provided module.
// Here `mod` needs to define a target, which is why earlier we made sure to
// set the releative field.
const mod_tests = b.addTest(.{
.root_module = mod,
});
// A run step that will run the test executable.
const run_mod_tests = b.addRunArtifact(mod_tests);
// Creates an executable that will run `test` blocks from the executable's
// root module. Note that test executables only test one module at a time,
// hence why we have to create two separate ones.
const exe_tests = b.addTest(.{
.root_module = exe.root_module,
});
// A run step that will run the second test executable.
const run_exe_tests = b.addRunArtifact(exe_tests);
// A top level step for running all tests. dependOn can be called multiple
// times and since the two run steps do not depend on one another, this will
// make the two of them run in parallel.
const test_step = b.step("test", "Run tests");
test_step.dependOn(&run_mod_tests.step);
test_step.dependOn(&run_exe_tests.step);
// Just like flags, top level steps are also listed in the `--help` menu.
//
// The Zig build system is entirely implemented in userland, which means
// that it cannot hook into private compiler APIs. All compilation work
// orchestrated by the build system will result in other Zig compiler
// subcommands being invoked with the right flags defined. You can observe
// these invocations when one fails (or you pass a flag to increase
// verbosity) to validate assumptions and diagnose problems.
//
// Lastly, the Zig build system is relatively simple and self-contained,
// and reading its source code will allow you to master it.
}

View File

@@ -1,84 +0,0 @@
.{
// This is the default name used by packages depending on this one. For
// example, when a user runs `zig fetch --save <url>`, this field is used
// as the key in the `dependencies` table. Although the user can choose a
// different name, most users will stick with this provided value.
//
// It is redundant to include "zig" in this name because it is already
// within the Zig package namespace.
.name = .envr,
// This is a [Semantic Version](https://semver.org/).
// In a future version of Zig it will be used for package deduplication.
.version = "0.3.0",
// Together with name, this represents a globally unique package
// identifier. This field is generated by the Zig toolchain when the
// package is first created, and then *never changes*. This allows
// unambiguous detection of one package being an updated version of
// another.
//
// When forking a Zig project, this id should be regenerated (delete the
// field and run `zig build`) if the upstream project is still maintained.
// Otherwise, the fork is *hostile*, attempting to take control over the
// original project's identity. Thus it is recommended to leave the comment
// on the following line intact, so that it shows up in code reviews that
// modify the field.
.fingerprint = 0xa89bf067266a3e10, // Changing this has security and trust implications.
// Tracks the earliest Zig version that the package considers to be a
// supported use case.
.minimum_zig_version = "0.16.0",
// This field is optional.
// Each dependency must either provide a `url` and `hash`, or a `path`.
// `zig build --fetch` can be used to fetch all dependencies of a package, recursively.
// Once all dependencies are fetched, `zig build` no longer requires
// internet connectivity.
.dependencies = .{
// .age = .{ .path = "zig-vendor/age-ffi/zig" },
.sqlite = .{ .path = "zig-vendor/zig-sqlite" },
// See `zig fetch --save <url>` for a command-line interface for adding dependencies.
//.example = .{
// // When updating this field to a new URL, be sure to delete the corresponding
// // `hash`, otherwise you are communicating that you expect to find the old hash at
// // the new URL. If the contents of a URL change this will result in a hash mismatch
// // which will prevent zig from using it.
// .url = "https://example.com/foo.tar.gz",
//
// // This is computed from the file contents of the directory of files that is
// // obtained after fetching `url` and applying the inclusion rules given by
// // `paths`.
// //
// // This field is the source of truth; packages do not come from a `url`; they
// // come from a `hash`. `url` is just one of many possible mirrors for how to
// // obtain a package matching this `hash`.
// //
// // Uses the [multihash](https://multiformats.io/multihash/) format.
// .hash = "...",
//
// // When this is provided, the package is found in a directory relative to the
// // build root. In this case the package's hash is irrelevant and therefore not
// // computed. This field and `url` are mutually exclusive.
// .path = "foo",
//
// // When this is set to `true`, a package is declared to be lazily
// // fetched. This makes the dependency only get fetched if it is
// // actually used.
// .lazy = false,
//},
},
// Specifies the set of files and directories that are included in this package.
// Only files and directories listed here are included in the `hash` that
// is computed for this package. Only files listed here will remain on disk
// when using the zig package manager. As a rule of thumb, one should list
// files required for compilation plus any license(s).
// Paths are relative to the build root. Use the empty string (`""`) to refer to
// the build root itself.
// A directory listed here means that all files within, recursively, are included.
.paths = .{
"build.zig",
"build.zig.zon",
"src",
// For example...
//"LICENSE",
//"README.md",
},
}

View File

@@ -46,23 +46,24 @@ COMMANDS := []CommandInfo {
}, },
{"version", "envr version", "Show envr's version", "", {}}, {"version", "envr version", "Show envr's version", "", {}},
{"edit-config", "envr edit-config", "Edit your config with your default editor", "", {}}, {"edit-config", "envr edit-config", "Edit your config with your default editor", "", {}},
{"nushell-completion", "envr nushell-completion", "Generate custom completions for nushell", "", {}}, {
"nushell-completion",
"envr nushell-completion",
"Generate custom completions for nushell",
"",
{},
},
} }
parse_args :: proc() -> (cmd: Command, ok: bool) { parse_args :: proc() -> (cmd: Command, ok: bool) {
args := os.args args := os.args
if len(args) < 2 { if len(args) < 2 || args[1] == "--help" || args[1] == "-h" {
print_usage() print_usage()
return Command{}, false return Command{}, false
} }
cmd.name = args[1] cmd.name = args[1]
if cmd.name == "--help" || cmd.name == "-h" {
print_usage()
return Command{}, false
}
cmd.args = make([dynamic]string) cmd.args = make([dynamic]string)
cmd.flags = make(map[string]string) cmd.flags = make(map[string]string)
cmd.bool_set = make(map[string]bool) cmd.bool_set = make(map[string]bool)

View File

@@ -1,56 +0,0 @@
/*
Copyright © 2025 NAME HERE <EMAIL ADDRESS>
*/
package cmd
import (
"fmt"
"strings"
"github.com/sbrow/envr/app"
"github.com/spf13/cobra"
)
// backupCmd represents the backup command
var backupCmd = &cobra.Command{
Use: "backup <path>",
Short: "Import a .env file into envr",
Aliases: []string{"add"},
Args: cobra.ExactArgs(1),
// Long: `Long desc`
RunE: func(cmd *cobra.Command, args []string) error {
path := args[0]
if len(strings.TrimSpace(path)) == 0 {
return fmt.Errorf("No path provided")
}
db, err := app.Open()
if err != nil {
return err
} else {
defer db.Close()
record := app.NewEnvFile(path)
if err := db.Insert(record); err != nil {
return err
} else {
fmt.Printf("Saved %s into the database", path)
return nil
}
}
},
}
func init() {
rootCmd.AddCommand(backupCmd)
// Here you will define your flags and configuration settings.
// Cobra supports Persistent Flags which will work for this command
// and all subcommands, e.g.:
// backupCmd.PersistentFlags().String("foo", "", "A help for foo")
// Cobra supports local flags which will only run when this command
// is called directly, e.g.:
// backupCmd.Flags().BoolP("toggle", "t", false, "Help message for toggle")
}

View File

@@ -1,109 +0,0 @@
package cmd
import (
"fmt"
"os"
"path/filepath"
"github.com/sbrow/envr/app"
"github.com/spf13/cobra"
)
var checkCmd = &cobra.Command{
Use: "check [path]",
Short: "check if files in the current directory are backed up",
// TODO: Long description for new check command
Args: cobra.MaximumNArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
// Accept an optional path arg, default to current working directory
var checkPath string
if len(args) > 0 {
checkPath = args[0]
} else {
cwd, err := os.Getwd()
if err != nil {
return fmt.Errorf("failed to get current working directory: %w", err)
}
checkPath = cwd
}
// Get absolute path
absPath, err := filepath.Abs(checkPath)
if err != nil {
return fmt.Errorf("failed to get absolute path: %w", err)
}
// Open database
db, err := app.Open()
if err != nil {
return fmt.Errorf("failed to open database: %w", err)
}
defer db.Close()
// Check if the path is a file or directory
info, err := os.Stat(absPath)
if err != nil {
return fmt.Errorf("failed to stat path: %w", err)
}
var filesInPath []string
if info.IsDir() {
// Find .env files in the specified directory
if err := db.CanScan(); err != nil {
return err
}
// Scan only the specified path for .env files
filesInPath, err = db.Scan([]string{absPath})
if err != nil {
return fmt.Errorf("failed to scan path for env files: %w", err)
}
} else {
// Path is a file, just check this specific file
filesInPath = []string{absPath}
}
// Get all backed up files from the database
envFiles, err := db.List()
if err != nil {
return fmt.Errorf("failed to list files from database: %w", err)
}
// Check which files are not backed up
var notBackedUp []string
for _, file := range filesInPath {
isBackedUp := false
for _, envFile := range envFiles {
if envFile.Path == file {
isBackedUp = true
break
}
}
if !isBackedUp {
notBackedUp = append(notBackedUp, file)
}
}
// Display results
if len(notBackedUp) == 0 {
if len(filesInPath) == 0 {
fmt.Println("No .env files found in the specified directory.")
} else {
fmt.Println("✓ All .env files in the directory are backed up.")
}
} else {
fmt.Printf("Found %d .env file(s) that are not backed up:\n", len(notBackedUp))
for _, file := range notBackedUp {
fmt.Printf(" %s\n", file)
}
fmt.Println("\nRun 'envr sync' to back up these files.")
}
return nil
},
}
func init() {
rootCmd.AddCommand(checkCmd)
}

View File

@@ -1,51 +0,0 @@
package cmd
import (
"os"
"github.com/olekukonko/tablewriter"
"github.com/sbrow/envr/app"
"github.com/spf13/cobra"
)
var depsCmd = &cobra.Command{
Use: "deps",
Short: "Check for missing binaries",
Long: `envr relies on external binaries for certain functionality.
The check command reports on which binaries are available and which are not.`,
RunE: func(cmd *cobra.Command, args []string) error {
db, err := app.Open()
if err != nil {
return err
} else {
defer db.Close()
features := db.Features()
table := tablewriter.NewWriter(os.Stdout)
table.Header([]string{"Feature", "Status"})
// Check Git
if features&app.Git == 1 {
table.Append([]string{"Git", "✓ Available"})
} else {
table.Append([]string{"Git", "✗ Missing"})
}
// Check fd
if features&app.Fd == app.Fd {
table.Append([]string{"fd", "✓ Available"})
} else {
table.Append([]string{"fd", "✗ Missing"})
}
table.Render()
return nil
}
},
}
func init() {
rootCmd.AddCommand(depsCmd)
}

View File

@@ -1,55 +0,0 @@
/*
Copyright © 2025 NAME HERE <EMAIL ADDRESS>
*/
package cmd
import (
"fmt"
"os"
"os/exec"
"path/filepath"
"github.com/spf13/cobra"
)
var editConfigCmd = &cobra.Command{
Use: "edit-config",
Short: "Edit your config with your default editor",
// Long: ``,
Run: func(cmd *cobra.Command, args []string) {
editor := os.Getenv("EDITOR")
if editor == "" {
fmt.Println("Error: $EDITOR environment variable is not set")
return
}
homeDir, err := os.UserHomeDir()
if err != nil {
fmt.Printf("Error getting home directory: %v\n", err)
return
}
configPath := filepath.Join(homeDir, ".envr", "config.json")
// Check if config file exists
if _, err := os.Stat(configPath); os.IsNotExist(err) {
fmt.Printf("Config file does not exist at %s. Run 'envr init' first.\n", configPath)
return
}
// Execute the editor
execCmd := exec.Command(editor, configPath)
execCmd.Stdin = os.Stdin
execCmd.Stdout = os.Stdout
execCmd.Stderr = os.Stderr
if err := execCmd.Run(); err != nil {
fmt.Printf("Error running editor: %v\n", err)
return
}
},
}
func init() {
rootCmd.AddCommand(editConfigCmd)
}

View File

@@ -1,96 +0,0 @@
package cmd
import (
"fmt"
"os"
"path/filepath"
"strings"
"github.com/AlecAivazis/survey/v2"
"github.com/sbrow/envr/app"
"github.com/spf13/cobra"
)
var initCmd = &cobra.Command{
Use: "init",
Short: "Set up envr",
Long: `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.`,
RunE: func(cmd *cobra.Command, args []string) error {
force, _ := cmd.Flags().GetBool("force")
config, _ := app.LoadConfig()
if config == nil || force {
keys, err := selectSSHKeys()
if err != nil {
return fmt.Errorf("Error selecting SSH keys: %v", err)
}
if len(keys) == 0 {
return fmt.Errorf("No SSH keys selected - Config not created")
}
cfg := app.NewConfig(keys)
if err := cfg.Save(); err != nil {
return err
}
fmt.Printf("Config initialized with %d SSH key(s). You are ready to use envr.\n", len(keys))
return nil
} else {
return fmt.Errorf(`You have already initialized envr.
Run again with the --force flag if you want to reinitialize.
`)
}
},
}
func init() {
initCmd.Flags().BoolP("force", "f", false, "Overwrite an existing config")
rootCmd.AddCommand(initCmd)
}
func selectSSHKeys() ([]string, error) {
homeDir, err := os.UserHomeDir()
if err != nil {
return nil, err
}
// TODO: Support reading from ssh-agent
sshDir := filepath.Join(homeDir, ".ssh")
entries, err := os.ReadDir(sshDir)
if err != nil {
return nil, fmt.Errorf("could not read ~/.ssh directory: %w", err)
}
var privateKeys []string
for _, entry := range entries {
name := entry.Name()
if !entry.IsDir() && !strings.HasSuffix(name, ".pub") &&
!strings.Contains(name, "known_hosts") && !strings.Contains(name, "config") {
privateKeys = append(privateKeys, filepath.Join(sshDir, name))
}
}
if len(privateKeys) == 0 {
return nil, fmt.Errorf("no SSH private keys found in ~/.ssh")
}
var selected []string
prompt := &survey.MultiSelect{
Message: "Select SSH private keys:",
Options: privateKeys,
}
err = survey.AskOne(prompt, &selected)
if err != nil {
return nil, err
}
return selected, nil
}

View File

@@ -1,69 +0,0 @@
package cmd
import (
"encoding/json"
"os"
"path/filepath"
"github.com/mattn/go-isatty"
"github.com/olekukonko/tablewriter"
"github.com/sbrow/envr/app"
"github.com/spf13/cobra"
)
type listEntry struct {
Directory string `json:"directory"`
Path string `json:"path"`
}
var listCmd = &cobra.Command{
Use: "list",
Short: "View your tracked files",
RunE: func(cmd *cobra.Command, args []string) error {
db, err := app.Open()
if err != nil {
return err
}
defer db.Close()
rows, err := db.List()
if err != nil {
return err
}
if isatty.IsTerminal(os.Stdout.Fd()) {
table := tablewriter.NewWriter(os.Stdout)
table.Header([]string{"Directory", "Path"})
for _, row := range rows {
path, err := filepath.Rel(row.Dir, row.Path)
if err != nil {
return err
}
table.Append([]string{row.Dir + "/", path})
}
table.Render()
} else {
var entries []listEntry
for _, row := range rows {
path, err := filepath.Rel(row.Dir, row.Path)
if err != nil {
return err
}
entries = append(entries, listEntry{
Directory: row.Dir + "/",
Path: path,
})
}
encoder := json.NewEncoder(os.Stdout)
return encoder.Encode(entries)
}
return nil
},
}
func init() {
rootCmd.AddCommand(listCmd)
}

View File

@@ -1,79 +0,0 @@
# envr command extern definitions for Nushell
# A tool for managing environment files and backups
export def tracked-paths [] {
(
^envr list
| from json
| each {
[$in.directory $in.path] | path join
}
)
}
export def untracked-paths [] {
(
^envr scan
| from json
)
}
# Complete shell types for completion command
def shells [] {
["bash", "zsh", "fish", "powershell"]
}
export extern envr [
...args: any
--help(-h) # Show help information
--toggle(-t) # Help message for toggle
]
export extern "envr backup" [
--help(-h) # Show help for backup command
path: path@untracked-paths # Path to .env file to backup
]
#TODO: envr backup path.
export extern "envr check" [
--help(-h) # Show help for check command
]
export extern "envr completion" [
shell: string@shells # Shell to generate completion for
--help(-h) # Show help for completion command
]
export extern "envr edit-config" [
--help(-h) # Show help for edit-config command
]
export extern "envr help" [
command?: string # Show help for specific command
]
export extern "envr init" [
--help(-h) # Show help for init command
]
export extern "envr list" [
--help(-h) # Show help for list command
]
export extern "envr remove" [
--help(-h) # Show help for remove command
path: path@tracked-paths
]
export extern "envr restore" [
--help(-h) # Show help for restore command
path: path@tracked-paths
]
export extern "envr scan" [
--help(-h) # Show help for scan command
]
export extern "envr sync" [
--help(-h) # Show help for sync command
]

View File

@@ -1,26 +0,0 @@
package cmd
import (
_ "embed"
"fmt"
"github.com/spf13/cobra"
)
//go:embed mod.nu
var completion string
// nushellCompletionCmd represents the nushellCompletion command
var nushellCompletionCmd = &cobra.Command{
Use: "nushell-completion",
Short: "Generate custom completions for nushell",
Long: `At time of writing, cobra does not natively support nushell,
so a custom command had to be written`,
Run: func(cmd *cobra.Command, args []string) {
fmt.Println(completion)
},
}
func init() {
rootCmd.AddCommand(nushellCompletionCmd)
}

View File

@@ -1,51 +0,0 @@
/*
Copyright © 2025 NAME HERE <EMAIL ADDRESS>
*/
package cmd
import (
"fmt"
"strings"
"github.com/sbrow/envr/app"
"github.com/spf13/cobra"
)
var removeCmd = &cobra.Command{
Use: "remove",
Short: "Remove a .env file from your database",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
path := args[0]
if len(strings.TrimSpace(path)) == 0 {
return fmt.Errorf("No path provided")
}
db, err := app.Open()
if err != nil {
return err
} else {
defer db.Close()
if err := db.Delete(path); err != nil {
return err
} else {
fmt.Printf("Removed %s from the database", path)
return nil
}
}
},
}
func init() {
rootCmd.AddCommand(removeCmd)
// Here you will define your flags and configuration settings.
// Cobra supports Persistent Flags which will work for this command
// and all subcommands, e.g.:
// removeCmd.PersistentFlags().String("foo", "", "A help for foo")
// Cobra supports local flags which will only run when this command
// is called directly, e.g.:
// removeCmd.Flags().BoolP("toggle", "t", false, "Help message for toggle")
}

View File

@@ -1,60 +0,0 @@
/*
Copyright © 2025 NAME HERE <EMAIL ADDRESS>
*/
package cmd
import (
"fmt"
"strings"
"github.com/sbrow/envr/app"
"github.com/spf13/cobra"
)
// restoreCmd represents the restore command
var restoreCmd = &cobra.Command{
Use: "restore",
Short: "Install a .env file from the database into your file system",
// Long: ``,
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
path := args[0]
if len(strings.TrimSpace(path)) == 0 {
return fmt.Errorf("No path provided")
}
db, err := app.Open()
if err != nil {
return err
} else {
defer db.Close()
record, err := db.Fetch(path)
if err != nil {
return err
} else {
err := record.Restore()
if err != nil {
return err
} else {
return nil
}
}
}
},
}
func init() {
rootCmd.AddCommand(restoreCmd)
// Here you will define your flags and configuration settings.
// Cobra supports Persistent Flags which will work for this command
// and all subcommands, e.g.:
// restoreCmd.PersistentFlags().String("foo", "", "A help for foo")
// Cobra supports local flags which will only run when this command
// is called directly, e.g.:
// restoreCmd.Flags().BoolP("toggle", "t", false, "Help message for toggle")
}

View File

@@ -1,66 +0,0 @@
package cmd
import (
"os"
"github.com/spf13/cobra"
)
var rootCmd = &cobra.Command{
Use: "envr",
Short: "Manage your .env files.",
Long: `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`,
}
// Execute adds all child commands to the root command and sets flags appropriately.
// This is called by main.main(). It only needs to happen once to the rootCmd.
func Execute() {
err := rootCmd.Execute()
if err != nil {
os.Exit(1)
}
}
func init() {
// Here you will define your flags and configuration settings.
// Cobra supports persistent flags, which, if defined here,
// will be global for your application.
// rootCmd.PersistentFlags().StringVar(&cfgFile, "config", "", "config file (default is $HOME/.envr.yaml)")
// Cobra also supports local flags, which will only run
// when this action is called directly.
// rootCmd.Flags().BoolP("toggle", "t", false, "Help message for toggle")
}
// Expose the root command for our generators.
func Root() *cobra.Command { return rootCmd }

View File

@@ -1,104 +0,0 @@
package cmd
import (
"encoding/json"
"fmt"
"os"
"github.com/AlecAivazis/survey/v2"
"github.com/mattn/go-isatty"
"github.com/sbrow/envr/app"
"github.com/spf13/cobra"
)
var scanCmd = &cobra.Command{
Use: "scan",
Short: "Find and select .env files for backup",
RunE: func(cmd *cobra.Command, args []string) error {
db, err := app.Open()
if err != nil {
return err
}
if db == nil {
return fmt.Errorf("No db was loaded")
}
if err := db.CanScan(); err != nil {
return err
}
files, err := db.Scan(nil)
if err != nil {
return err
}
if len(files) == 0 {
return fmt.Errorf("No .env files found to add.")
}
if isatty.IsTerminal(os.Stdout.Fd()) {
selectedFiles, err := selectEnvFiles(files)
if err != nil {
return err
}
// Insert selected files into database
var addedCount int
for _, file := range selectedFiles {
envFile := app.NewEnvFile(file)
err := db.Insert(envFile)
if err != nil {
fmt.Printf("Error adding %s: %v\n", file, err)
} else {
addedCount++
}
}
// Close database with write mode to persist changes
if addedCount > 0 {
err = db.Close()
if err != nil {
return fmt.Errorf("Error saving changes: %v\n", err)
} else {
fmt.Printf("Successfully added %d file(s) to backup.\n", addedCount)
return nil
}
} else {
err = db.Close()
if err != nil {
return fmt.Errorf("Error closing database: %v\n", err)
}
fmt.Println("No files were added.")
return nil
}
} else {
output, err := json.Marshal(files)
if err != nil {
return fmt.Errorf("Error marshaling files to JSON: %v", err)
}
fmt.Println(string(output))
return nil
}
},
}
func init() {
rootCmd.AddCommand(scanCmd)
}
func selectEnvFiles(files []string) ([]string, error) {
var selectedFiles []string
prompt := &survey.MultiSelect{
Message: "Select .env files to backup:",
Options: files,
}
err := survey.AskOne(prompt, &selectedFiles)
if err != nil {
return nil, err
}
return selectedFiles, nil
}

View File

@@ -1,101 +0,0 @@
package cmd
import (
"encoding/json"
"os"
"github.com/mattn/go-isatty"
"github.com/olekukonko/tablewriter"
"github.com/sbrow/envr/app"
"github.com/spf13/cobra"
)
var syncCmd = &cobra.Command{
Use: "sync",
Short: "Update or restore your env backups",
RunE: func(cmd *cobra.Command, args []string) error {
db, err := app.Open()
if err != nil {
return err
} else {
defer db.Close()
files, err := db.List()
if err != nil {
return err
} else {
type syncResult struct {
Path string `json:"path"`
Status string `json:"status"`
}
var results []syncResult
for _, file := range files {
// Syncronize the filesystem with the database.
oldPath := file.Path
changed, err := db.Sync(&file)
var status string
switch changed {
case app.BackedUp:
status = "Backed Up"
if err := db.Insert(file); err != nil {
return err
}
case app.Restored:
fallthrough
case app.RestoredAndDirUpdated:
status = "Restored"
case app.Error:
if err == nil {
panic("err cannot be nil when Sync returns Error")
}
status = err.Error()
case app.Noop:
status = "OK"
case app.DirUpdated:
status = "Moved"
default:
panic("Unknown result")
}
if changed&app.DirUpdated == app.DirUpdated {
if err := db.Delete(oldPath); err != nil {
return err
}
}
if db.UpdateRequired(changed) {
if err := db.Insert(file); err != nil {
return err
}
}
results = append(results, syncResult{
Path: file.Path,
Status: status,
})
}
if isatty.IsTerminal(os.Stdout.Fd()) {
table := tablewriter.NewWriter(os.Stdout)
table.Header([]string{"File", "Status"})
for _, result := range results {
table.Append([]string{result.Path, result.Status})
}
table.Render()
} else {
encoder := json.NewEncoder(os.Stdout)
return encoder.Encode(results)
}
return nil
}
}
},
}
func init() {
rootCmd.AddCommand(syncCmd)
}

View File

@@ -1,36 +0,0 @@
package cmd
import (
"fmt"
"github.com/spf13/cobra"
)
var (
version = "dev"
commit = "none"
date = "unknown"
)
var long bool
// versionCmd represents the version command
// Deprecated: Remove when Zig has the chance to emit help
var versionCmd = &cobra.Command{
Use: "version",
Short: "Show envr's version",
Run: func(cmd *cobra.Command, args []string) {
if long {
fmt.Printf("envr version %s\n", version)
fmt.Printf("commit: %s\n", commit)
fmt.Printf("built: %s\n", date)
} else {
fmt.Printf("%s\n", version)
}
},
}
func init() {
versionCmd.Flags().BoolVarP(&long, "long", "l", false, "Show all version information")
rootCmd.AddCommand(versionCmd)
}

View File

@@ -17,18 +17,22 @@ cmd_backup :: proc(cmd: ^Command) {
file, ok := new_env_file(path) file, ok := new_env_file(path)
if !ok { if !ok {
// TODO: log a message
return return
} }
db, db_ok := db_open() db, db_ok := db_open()
if !db_ok { if !db_ok {
// TODO: log a message
return return
} }
defer db_close(&db) defer db_close(&db)
if !db_insert(&db, file) { if !db_insert(&db, file) {
// TODO: log a message
return return
} }
fmt.printf("Saved %s into the database\n", path) fmt.printf("Saved %s into the database\n", path)
} }

View File

@@ -3,7 +3,6 @@ package main
import "core:fmt" import "core:fmt"
import "core:os" import "core:os"
import "core:path/filepath" import "core:path/filepath"
import "core:strings"
cmd_check :: proc(cmd: ^Command) { cmd_check :: proc(cmd: ^Command) {
feats := check_features() feats := check_features()

View File

@@ -1,6 +1,9 @@
package main package main
import "core:fmt" import "core:fmt"
import "core:io"
import "core:os"
import "core:terminal"
cmd_deps :: proc(cmd: ^Command) { cmd_deps :: proc(cmd: ^Command) {
feats := check_features() feats := check_features()
@@ -20,11 +23,13 @@ cmd_deps :: proc(cmd: ^Command) {
append(&rows, []string{"fd", "\u2717 Missing"}) append(&rows, []string{"fd", "\u2717 Missing"})
} }
if .Age in feats { if terminal.is_terminal(os.stdout) {
append(&rows, []string{"age", "\u2713 Available"}) w := io.to_writer(os.to_writer(os.stdout))
render_table(w, headers, rows[:])
} else { } else {
append(&rows, []string{"age", "\u2717 Missing"}) w := io.to_writer(os.to_writer(os.stdout))
render_json_rows(w, headers, rows[:])
io.write_string(w, "\n")
}
} }
render_table(headers, rows[:])
}

View File

@@ -3,7 +3,6 @@ package main
import "core:fmt" import "core:fmt"
import "core:os" import "core:os"
import "core:path/filepath" import "core:path/filepath"
import "core:strings"
cmd_edit_config :: proc(cmd: ^Command) { cmd_edit_config :: proc(cmd: ^Command) {
editor := os.get_env("EDITOR", context.allocator) editor := os.get_env("EDITOR", context.allocator)
@@ -47,3 +46,4 @@ cmd_edit_config :: proc(cmd: ^Command) {
os.exit(int(state.exit_code)) os.exit(int(state.exit_code))
} }
} }

View File

@@ -18,7 +18,8 @@ cmd_init :: proc(cmd: ^Command) {
} }
if len(keys) == 0 { if len(keys) == 0 {
fmt.println("No SSH private keys found in ~/.ssh") fmt.println("No ssh-ed25519 keys found in ~/.ssh")
fmt.println("Generate one with: ssh-keygen -t ed25519")
return return
} }

View File

@@ -2,8 +2,11 @@ package main
import "core:encoding/json" import "core:encoding/json"
import "core:fmt" import "core:fmt"
import "core:io"
import "core:os"
import "core:path/filepath" import "core:path/filepath"
import "core:strings" import "core:strings"
import "core:terminal"
ListEntry :: struct { ListEntry :: struct {
Directory: string `json:"directory"`, Directory: string `json:"directory"`,
@@ -23,12 +26,12 @@ cmd_list :: proc(cmd: ^Command) {
} }
defer delete(rows) defer delete(rows)
if is_tty() { if terminal.is_terminal(os.stdout) {
headers := []string{"Directory", "Path"} headers := []string{"Directory", "Path"}
table_rows := make([dynamic][]string, 0, len(rows)) table_rows := make([dynamic][]string, 0, len(rows), context.temp_allocator)
for row in rows { for row in rows {
dir_str := strings.concatenate({row.Dir, "/"}) dir_str := strings.concatenate({row.Dir, "/"}, context.temp_allocator)
filename := filepath.base(row.Path) filename := filepath.base(row.Path)
row_slice := make([]string, 2) row_slice := make([]string, 2)
row_slice[0] = dir_str row_slice[0] = dir_str
@@ -36,15 +39,19 @@ cmd_list :: proc(cmd: ^Command) {
append(&table_rows, row_slice) append(&table_rows, row_slice)
} }
render_table(headers, table_rows[:]) w := io.to_writer(os.to_writer(os.stdout))
render_table(w, headers, table_rows[:])
} else { } else {
entries: [dynamic]ListEntry entries: [dynamic]ListEntry
for row in rows { for row in rows {
filename := filepath.base(row.Path) filename := filepath.base(row.Path)
append(&entries, ListEntry{ append(
Directory = strings.concatenate({row.Dir, "/"}), &entries,
ListEntry {
Directory = strings.concatenate({row.Dir, "/"}, context.temp_allocator),
Path = filename, Path = filename,
}) },
)
} }
data, marshal_err := json.marshal(entries[:]) data, marshal_err := json.marshal(entries[:])
@@ -55,3 +62,4 @@ cmd_list :: proc(cmd: ^Command) {
fmt.println(string(data)) fmt.println(string(data))
} }
} }

View File

@@ -2,6 +2,8 @@ package main
import "core:encoding/json" import "core:encoding/json"
import "core:fmt" import "core:fmt"
import "core:os"
import "core:terminal"
cmd_scan :: proc(cmd: ^Command) { cmd_scan :: proc(cmd: ^Command) {
feats := check_features() feats := check_features()
@@ -49,7 +51,7 @@ cmd_scan :: proc(cmd: ^Command) {
return return
} }
if !is_tty() { if !terminal.is_terminal(os.stdout) {
output, marshal_err := json.marshal(files[:]) output, marshal_err := json.marshal(files[:])
if marshal_err != nil { if marshal_err != nil {
fmt.printf("Error marshaling files to JSON: %v\n", marshal_err) fmt.printf("Error marshaling files to JSON: %v\n", marshal_err)

View File

@@ -2,13 +2,17 @@ package main
import "core:encoding/json" import "core:encoding/json"
import "core:fmt" import "core:fmt"
import "core:io"
import "core:os"
import "core:strings" import "core:strings"
import "core:terminal"
SyncEntry :: struct { SyncEntry :: struct {
Path: string `json:"path"`, Path: string `json:"path"`,
Status: string `json:"status"`, Status: string `json:"status"`,
} }
// TODO: Check for quiet failures.
cmd_sync :: proc(cmd: ^Command) { cmd_sync :: proc(cmd: ^Command) {
db, db_ok := db_open() db, db_ok := db_open()
if !db_ok { if !db_ok {
@@ -31,28 +35,22 @@ cmd_sync :: proc(cmd: ^Command) {
result, err_msg := db_sync(&db, &file) result, err_msg := db_sync(&db, &file)
status: string status: string
s := i32(result) is_dir_updated := .DirUpdated in result
is_error := (s & i32(SyncResult.Error)) != 0
is_backed := (s & i32(SyncResult.BackedUp)) != 0
is_restored := (s & i32(SyncResult.Restored)) != 0
is_dir_updated := (s & i32(SyncResult.DirUpdated)) != 0
if is_error { switch {
case .Error in result:
if len(err_msg) > 0 { if len(err_msg) > 0 {
status = err_msg status = err_msg
} else { } else {
status = "error" status = "error"
} }
} else if is_backed { case .BackedUp in result:
status = "Backed Up" status = "Backed Up"
if !db_insert(&db, file) { case .Restored in result:
return
}
} else if is_restored {
status = "Restored" status = "Restored"
} else if is_dir_updated && !is_restored { case .DirUpdated in result:
status = "Moved" status = "Moved"
} else { case:
status = "OK" status = "OK"
} }
@@ -72,7 +70,7 @@ cmd_sync :: proc(cmd: ^Command) {
append(&results, SyncEntry{Path = path_str, Status = status_str}) append(&results, SyncEntry{Path = path_str, Status = status_str})
} }
if is_tty() { if terminal.is_terminal(os.stdout) {
headers := []string{"File", "Status"} headers := []string{"File", "Status"}
table_rows := make([dynamic][]string, 0, len(results)) table_rows := make([dynamic][]string, 0, len(results))
@@ -83,7 +81,8 @@ cmd_sync :: proc(cmd: ^Command) {
append(&table_rows, row_slice) append(&table_rows, row_slice)
} }
render_table(headers, table_rows[:]) w := io.to_writer(os.to_writer(os.stdout))
render_table(w, headers, table_rows[:])
} else { } else {
data, marshal_err := json.marshal(results[:]) data, marshal_err := json.marshal(results[:])
if marshal_err != nil { if marshal_err != nil {
@@ -93,3 +92,4 @@ cmd_sync :: proc(cmd: ^Command) {
fmt.println(string(data)) fmt.println(string(data))
} }
} }

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.println(VERSION)
}

View File

@@ -61,9 +61,9 @@ envr_dir :: proc() -> string {
return dir return dir
} }
data_age_path :: proc() -> string { data_encrypted_path :: proc() -> string {
dir := envr_dir() dir := envr_dir()
path, _ := filepath.join([]string{dir, "data.age"}) path, _ := filepath.join([]string{dir, "data.envr"})
return path return path
} }
@@ -103,6 +103,9 @@ find_ssh_private_keys :: proc() -> (keys: [dynamic]string, ok: bool) {
} }
full_path, _ := filepath.join([]string{ssh_dir, name}) full_path, _ := filepath.join([]string{ssh_dir, name})
if !is_ed25519_key(full_path) {
continue
}
append(&keys, full_path) append(&keys, full_path)
} }

338
crypto.odin Normal file
View File

@@ -0,0 +1,338 @@
package main
import "core:fmt"
import "core:mem"
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,
}
sodium_initialized: bool
ensure_sodium :: proc() -> bool {
if sodium_initialized {
return true
}
rc := sodium_init()
if rc < 0 {
fmt.println("Error: libsodium initialization failed")
return false
}
sodium_initialized = true
return true
}
X25519Keypair :: struct {
Public: [CRYPTO_BOX_PUBLICKEY_BYTES]u8,
Private: [CRYPTO_BOX_SECRETKEY_BYTES]u8,
}
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
}
encrypt :: proc(plaintext: []u8, keys: []SshKeyPair) -> (ciphertext: []u8, ok: bool) {
if !ensure_sodium() {
return
}
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 !ensure_sodium() {
return
}
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
}

102
crypto_test.odin Normal file
View File

@@ -0,0 +1,102 @@
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_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")
}

197
db.odin
View File

@@ -1,6 +1,5 @@
package main package main
import "core:c"
import "core:crypto/hash" import "core:crypto/hash"
import "core:encoding/hex" import "core:encoding/hex"
import "core:encoding/json" import "core:encoding/json"
@@ -12,14 +11,16 @@ import "core:time"
import "sqlite" import "sqlite"
SyncResult :: enum i32 { SyncFlagEnum :: enum {
Noop = 0, Noop,
DirUpdated = 1, DirUpdated,
Restored = 1 << 1, Restored,
BackedUp = 1 << 2, BackedUp,
Error = 1 << 3, Error,
} }
SyncFlag :: bit_set[SyncFlagEnum]
SyncDirection :: enum { SyncDirection :: enum {
TrustDatabase, TrustDatabase,
TrustFilesystem, TrustFilesystem,
@@ -53,8 +54,8 @@ db_open :: proc() -> (Db, bool) {
return Db{}, false return Db{}, false
} }
age_path := data_age_path() data_path := data_encrypted_path()
_, stat_err := os.stat(age_path, context.allocator) _, stat_err := os.stat(data_path, context.allocator)
db: ^rawptr db: ^rawptr
rc := sqlite.db_open(":memory:", &db) rc := sqlite.db_open(":memory:", &db)
@@ -72,7 +73,7 @@ db_open :: proc() -> (Db, bool) {
} }
if stat_err == nil { if stat_err == nil {
if !db_restore_from_age(db, cfg) { if !db_restore_from_encrypted(db, cfg) {
sqlite.db_close(db) sqlite.db_close(db)
return Db{}, false return Db{}, false
} }
@@ -91,8 +92,34 @@ db_close :: proc(d: ^Db) {
return return
} }
db_encrypt_file(tmp_path, d.cfg.Keys[:]) sqlite_data, read_err := os.read_entire_file_from_path(tmp_path, context.allocator)
os.remove(tmp_path) os.remove(tmp_path)
if read_err != nil {
fmt.printf("Error reading vacuumed database: %v\n", read_err)
sqlite.db_close(d.db)
return
}
encrypted, enc_ok := encrypt(sqlite_data, d.cfg.Keys[:])
delete(sqlite_data)
if !enc_ok {
fmt.println("Error: encryption failed")
sqlite.db_close(d.db)
return
}
data_path := data_encrypted_path()
envr_d := envr_dir()
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)
sqlite.db_close(d.db)
return
}
d.changed = false d.changed = false
} }
sqlite.db_close(d.db) sqlite.db_close(d.db)
@@ -158,14 +185,29 @@ db_vacuum_to_file :: proc(db: ^rawptr, path: string) -> bool {
return true return true
} }
db_restore_from_age :: proc(db: ^rawptr, cfg: Config) -> bool { db_restore_from_encrypted :: proc(db: ^rawptr, cfg: Config) -> bool {
tmp_path := make_temp_path() data_path := data_encrypted_path()
defer os.remove(tmp_path) encrypted_data, read_err := os.read_entire_file_from_path(data_path, context.temp_allocator)
if read_err != nil {
if !db_decrypt_to_file(tmp_path, cfg.Keys[:]) { fmt.printf("Error reading encrypted database: %v\n", read_err)
return false return false
} }
plaintext, dec_ok := decrypt(encrypted_data, cfg.Keys[:])
if !dec_ok {
fmt.println("Error: decryption failed")
return false
}
defer delete(plaintext)
tmp_path := make_temp_path()
write_err := os.write_entire_file(tmp_path, plaintext)
if write_err != nil {
fmt.printf("Error writing temp database: %v\n", write_err)
return false
}
defer os.remove(tmp_path)
if !db_attach_and_copy(db, tmp_path) { if !db_attach_and_copy(db, tmp_path) {
return false return false
} }
@@ -173,93 +215,6 @@ db_restore_from_age :: proc(db: ^rawptr, cfg: Config) -> bool {
return true return true
} }
db_decrypt_to_file :: proc(tmp_path: string, keys: []SshKeyPair) -> bool {
age_path := data_age_path()
args := make([dynamic]string)
append(&args, "age")
append(&args, "--decrypt")
append(&args, "-o")
append(&args, tmp_path)
for key in keys {
append(&args, "-i")
append(&args, key.Private)
}
append(&args, age_path)
desc := os.Process_Desc {
command = args[:],
stdout = os.stderr,
stderr = os.stderr,
}
p, err := os.process_start(desc)
if err != nil {
fmt.printf("Error running age decrypt: %v\n", err)
return false
}
state, wait_err := os.process_wait(p)
if wait_err != nil {
fmt.printf("Error waiting for age: %v\n", wait_err)
return false
}
if state.exit_code != 0 {
fmt.println("Error: age decryption failed")
return false
}
return true
}
db_encrypt_file :: proc(tmp_path: string, keys: []SshKeyPair) -> bool {
age_path := data_age_path()
envr_d := envr_dir()
os.mkdir_all(envr_d)
args := make([dynamic]string)
append(&args, "age")
append(&args, "--encrypt")
for key in keys {
append(&args, "-r")
pub_data, pub_err := os.read_entire_file_from_path(key.Public, context.allocator)
if pub_err != nil {
fmt.printf("Error reading public key: %s\n", key.Public)
return false
}
pub_str := string(pub_data)
if strings.has_suffix(pub_str, "\n") {
pub_str = pub_str[:len(pub_str) - 1]
}
append(&args, pub_str)
}
append(&args, "-o")
append(&args, age_path)
append(&args, tmp_path)
desc := os.Process_Desc {
command = args[:],
stdout = os.stderr,
stderr = os.stderr,
}
p, err := os.process_start(desc)
if err != nil {
fmt.printf("Error running age encrypt: %v\n", err)
return false
}
state, wait_err := os.process_wait(p)
if wait_err != nil {
fmt.printf("Error waiting for age: %v\n", wait_err)
return false
}
if state.exit_code != 0 {
fmt.println("Error: age encryption failed")
return false
}
return true
}
db_attach_and_copy :: proc(mem_db: ^rawptr, src_path: string) -> bool { db_attach_and_copy :: proc(mem_db: ^rawptr, src_path: string) -> bool {
b: strings.Builder b: strings.Builder
strings.builder_init(&b) strings.builder_init(&b)
@@ -495,9 +450,8 @@ string_to_cstring :: proc(s: string) -> cstring {
return cs return cs
} }
db_update_required :: proc(status: SyncResult) -> bool { db_update_required :: proc(status: SyncFlag) -> bool {
s := i32(status) return .BackedUp in status || .DirUpdated in status
return (s & (i32(SyncResult.BackedUp) | i32(SyncResult.DirUpdated))) != 0
} }
shares_remote :: proc(f: ^EnvFile, remotes: []string) -> bool { shares_remote :: proc(f: ^EnvFile, remotes: []string) -> bool {
@@ -556,9 +510,8 @@ env_file_backup :: proc(f: ^EnvFile) -> bool {
return true return true
} }
env_file_sync :: proc(f: ^EnvFile, dir: SyncDirection, d: ^Db) -> (SyncResult, string) { env_file_sync :: proc(f: ^EnvFile, dir: SyncDirection, d: ^Db) -> (SyncFlag, string) {
result: SyncResult = .Noop result: SyncFlag = {}
err_msg: string
_, stat_err := os.stat(f.Dir, context.allocator) _, stat_err := os.stat(f.Dir, context.allocator)
if stat_err != nil { if stat_err != nil {
@@ -567,18 +520,18 @@ env_file_sync :: proc(f: ^EnvFile, dir: SyncDirection, d: ^Db) -> (SyncResult, s
if d != nil { if d != nil {
dirs, dirs_ok := find_moved_dirs(d, f) dirs, dirs_ok := find_moved_dirs(d, f)
if !dirs_ok { if !dirs_ok {
return .Error, "failed to find moved dirs" return {.Error}, "failed to find moved dirs"
} }
moved_dirs = dirs moved_dirs = dirs
} }
if len(moved_dirs) == 0 { if len(moved_dirs) == 0 {
return .Error, "directory missing" return {.Error}, "directory missing"
} else if len(moved_dirs) == 1 { } else if len(moved_dirs) == 1 {
update_dir(f, moved_dirs[0]) update_dir(f, moved_dirs[0])
result = .DirUpdated result = {.DirUpdated}
} else { } else {
return .Error, "multiple directories found" return {.Error}, "multiple directories found"
} }
} }
@@ -587,11 +540,10 @@ env_file_sync :: proc(f: ^EnvFile, dir: SyncDirection, d: ^Db) -> (SyncResult, s
write_err := os.write_entire_file(f.Path, f.contents) write_err := os.write_entire_file(f.Path, f.contents)
if write_err != nil { if write_err != nil {
msg, _ := strings.concatenate({"failed to write file: ", fmt.tprintf("%v", write_err)}) msg, _ := strings.concatenate({"failed to write file: ", fmt.tprintf("%v", write_err)})
return .Error, msg return {.Error}, msg
} }
s := i32(result) | i32(SyncResult.Restored) return result + {.Restored}, ""
return SyncResult(s), ""
} }
data, read_err := os.read_entire_file_from_path(f.Path, context.allocator) data, read_err := os.read_entire_file_from_path(f.Path, context.allocator)
@@ -599,7 +551,7 @@ env_file_sync :: proc(f: ^EnvFile, dir: SyncDirection, d: ^Db) -> (SyncResult, s
msg, _ := strings.concatenate( msg, _ := strings.concatenate(
{"failed to read file for SHA comparison: ", fmt.tprintf("%v", read_err)}, {"failed to read file for SHA comparison: ", fmt.tprintf("%v", read_err)},
) )
return .Error, msg return {.Error}, msg
} }
digest := hash.hash_bytes(hash.Algorithm.SHA256, data) digest := hash.hash_bytes(hash.Algorithm.SHA256, data)
@@ -615,21 +567,20 @@ env_file_sync :: proc(f: ^EnvFile, dir: SyncDirection, d: ^Db) -> (SyncResult, s
write_err := os.write_entire_file(f.Path, f.contents) write_err := os.write_entire_file(f.Path, f.contents)
if write_err != nil { if write_err != nil {
msg, _ := strings.concatenate({"failed to write file: ", fmt.tprintf("%v", write_err)}) msg, _ := strings.concatenate({"failed to write file: ", fmt.tprintf("%v", write_err)})
return .Error, msg return {.Error}, msg
} }
s := i32(result) | i32(SyncResult.Restored) return result + {.Restored}, ""
return SyncResult(s), ""
case .TrustFilesystem: case .TrustFilesystem:
if !env_file_backup(f) { if !env_file_backup(f) {
return .Error, "failed to backup file" return {.Error}, "failed to backup file"
} }
return .BackedUp, "" return result + {.BackedUp}, ""
} }
return result, "" return result, ""
} }
db_sync :: proc(d: ^Db, f: ^EnvFile) -> (SyncResult, string) { db_sync :: proc(d: ^Db, f: ^EnvFile) -> (SyncFlag, string) {
return env_file_sync(f, .TrustFilesystem, d) return env_file_sync(f, .TrustFilesystem, d)
} }

333
db_integration_test.odin Normal file
View File

@@ -0,0 +1,333 @@
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_attach_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)
tmp_db_path := fmt.tprintf("/tmp/envr-test-attach-%d.db", os.get_pid())
write_err := os.write_entire_file(tmp_db_path, plaintext)
testing.expectf(t, write_err == nil, "failed to write temp db: %v", write_err)
if write_err != nil {
return
}
defer os.remove(tmp_db_path)
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)
create_sql := "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(mem_db, string_to_cstring(create_sql), nil, nil, nil)
testing.expect(t, rc == sqlite.OK, "failed to create table")
attach_ok := db_attach_and_copy(mem_db, tmp_db_path)
testing.expect(t, attach_ok, "failed to attach and copy")
sql := "SELECT path FROM envr_env_files"
stmt: ^rawptr
rc = sqlite.prepare_v2(mem_db, string_to_cstring(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 := cstring_to_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"})
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)
}
}

View File

@@ -1,36 +1,267 @@
package main package main
import "core:fmt"
import "core:os"
import "core:testing" 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 := "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, string_to_cstring(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)
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(&d, f), "insert should succeed")
fetched, fetch_ok := db_fetch(&d, "/project/.env")
testing.expect(t, fetch_ok, "fetch should succeed")
if !fetch_ok do return
defer delete(fetched.Remotes)
testing.expect(t, fetched.Path == "/project/.env", "path mismatch")
testing.expect(t, fetched.Sha256 == "abc123", "sha mismatch")
testing.expect(t, fetched.contents == "SECRET=value", "contents mismatch")
testing.expect(t, len(fetched.Remotes) == 1, "remotes count mismatch")
testing.expect(t, fetched.Remotes[0] == "git@github.com:user/repo.git", "remote mismatch")
}
@(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)
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(fetched.Remotes)
testing.expect(t, fetched.contents == "KEY=new", "contents should be updated")
testing.expect(t, fetched.Sha256 == "sha2", "sha should be updated")
}
@(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"})
f2 := make_test_env_file("/proj2/.env", "sha2", "B=2", []string{"git@github.com:b/repo.git"})
f3 := make_test_env_file("/proj3/.env", "sha3", "C=3")
defer delete(f1.Remotes)
defer delete(f2.Remotes)
defer delete(f3.Remotes)
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)
testing.expect(t, len(results) == 3, "should have 3 rows")
}
@(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_vacuum_to_file :: 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)
vacuum_path := fmt.tprintf("/tmp/envr-test-vacuum-%d.db", os.get_pid())
defer os.remove(vacuum_path)
testing.expect(t, db_vacuum_to_file(d.db, vacuum_path), "vacuum should succeed")
_, stat_err := os.stat(vacuum_path, context.allocator)
testing.expect(t, stat_err == nil, "vacuumed file should exist")
if stat_err != nil do return
data, read_err := os.read_entire_file_from_path(vacuum_path, context.allocator)
testing.expect(t, read_err == nil, "should read vacuumed file")
if read_err != nil do return
defer delete(data)
testing.expect(t, len(data) > 0, "vacuumed file should be non-empty")
}
@(test) @(test)
test_db_update_required_noop :: proc(t: ^testing.T) { test_db_update_required_noop :: proc(t: ^testing.T) {
testing.expect(t, !db_update_required(.Noop), "Noop should not require update") testing.expect(t, !db_update_required({}), "Noop should not require update")
} }
@(test) @(test)
test_db_update_required_backed_up :: proc(t: ^testing.T) { test_db_update_required_backed_up :: proc(t: ^testing.T) {
testing.expect(t, db_update_required(.BackedUp), "BackedUp should require update") testing.expect(t, db_update_required({.BackedUp}), "BackedUp should require update")
} }
@(test) @(test)
test_db_update_required_dir_updated :: proc(t: ^testing.T) { test_db_update_required_dir_updated :: proc(t: ^testing.T) {
testing.expect(t, db_update_required(.DirUpdated), "DirUpdated should require update") testing.expect(t, db_update_required({.DirUpdated}), "DirUpdated should require update")
} }
@(test) @(test)
test_db_update_required_restored :: proc(t: ^testing.T) { test_db_update_required_restored :: proc(t: ^testing.T) {
testing.expect(t, !db_update_required(.Restored), "Restored alone should not require update") testing.expect(t, !db_update_required({.Restored}), "Restored alone should not require update")
} }
@(test) @(test)
test_db_update_required_error :: proc(t: ^testing.T) { test_db_update_required_error :: proc(t: ^testing.T) {
testing.expect(t, !db_update_required(.Error), "Error alone should not require update") testing.expect(t, !db_update_required({.Error}), "Error alone should not require update")
} }
@(test) @(test)
test_db_update_required_combined :: proc(t: ^testing.T) { test_db_update_required_combined :: proc(t: ^testing.T) {
s := i32(SyncResult.DirUpdated) | i32(SyncResult.Restored) combined := SyncFlag{.DirUpdated, .Restored}
combined := SyncResult(s)
testing.expect(t, db_update_required(combined), "DirUpdated|Restored should require update") testing.expect(t, db_update_required(combined), "DirUpdated|Restored should require update")
} }

View File

@@ -8,7 +8,6 @@ import "core:strings"
Feature :: enum { Feature :: enum {
Git, Git,
Fd, Fd,
Age,
} }
AvailableFeatures :: bit_set[Feature] AvailableFeatures :: bit_set[Feature]
@@ -31,9 +30,6 @@ check_features :: proc() -> AvailableFeatures {
if find_binary(paths, "fd") != "" { if find_binary(paths, "fd") != "" {
feats += {.Fd} feats += {.Fd}
} }
if find_binary(paths, "age") != "" {
feats += {.Age}
}
return feats return feats
} }

View File

@@ -1,21 +0,0 @@
{
"db_path": "~/.envr/data.age",
"keys": [
{
"private": "~/.ssh/id_ed25519",
"public": "~/.ssh/id_ed25519.pub"
}
],
"scan": {
"matcher": "\\.env",
"exclude": [
"*\\.envrc",
"\\.local",
"node_modules",
"vendor"
],
"include": [
"~"
]
}
}

Binary file not shown.

Binary file not shown.

View File

@@ -1,5 +0,0 @@
age-encryption.org/v1
-> ssh-ed25519 Boe0UQ 2ngx7jSJ8/yuAzTgeiiCTYZRSkBCeJfaHTL0u7k6ziU
0XmEy0bOTeW1MF9ev32n4xISPDl9UQNHzEB0vsZHDuU
--- UV7IjWFCCg79Pf3T9vUWBxT4MhgeARWp6E+LK9tMy1g
u‡No2Zÿꥡé–Ý…++˜‡°ð¾ÓYÏóíð<C3AD>y:æ@' xP¾

View File

@@ -1 +0,0 @@
Hello, World!

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

View File

@@ -40,7 +40,6 @@
}; };
treefmt = { treefmt = {
# Used to find the project root
projectRootFile = "flake.nix"; projectRootFile = "flake.nix";
settings.global.excludes = [ settings.global.excludes = [
".direnv/**" ".direnv/**"
@@ -50,66 +49,51 @@
".env.local" ".env.local"
]; ];
# Format nix files
programs.nixpkgs-fmt.enable = true; programs.nixpkgs-fmt.enable = true;
# programs.deadnix.enable = true;
# Format go files
programs.goimports.enable = true;
}; };
packages.default = pkgs.buildGoModule rec { packages.default = pkgs.stdenv.mkDerivation rec {
pname = "envr"; pname = "envr";
version = "0.2.0"; version = "0.2.0";
src = ./.; src = ./.;
# If the build complains, uncomment this line
# vendorHash = "sha256:0000000000000000000000000000000000000000000000000000";
vendorHash = "sha256-aC82an6vYifewx4amfXLzk639jz9fF5bD5cF6krY0Ks=";
nativeBuildInputs = [ pkgs.installShellFiles ]; nativeBuildInputs = [
pkgs.unstable.odin
ldflags = [ pkgs.pkg-config
"-X github.com/sbrow/envr/cmd.version=v${version}"
# "-X github.com/sbrow/envr/cmd.commit=$(git rev-parse HEAD)"
# "-X github.com/sbrow/envr/cmd.date=$(date -u +%Y-%m-%dT%H:%M:%SZ)"
]; ];
postBuild = '' buildInputs = [
# Generate man pages pkgs.libsodium
$GOPATH/bin/docgen -out ./man -format man pkgs.sqlite
];
buildPhase = ''
runHook preBuild
echo '${version}' > version.txt
odin build . -o:speed -out:${pname}
runHook postBuild
''; '';
postInstall = '' installPhase = ''
# Install man pages runHook preInstall
installManPage ./man/*.1 install -Dm755 ${pname} $out/bin/${pname}
runHook postInstall
''; '';
}; };
devShells.default = pkgs.mkShell devShells.default = pkgs.mkShell {
{
buildInputs = with pkgs; [ buildInputs = with pkgs; [
fd fd
nushell nushell
go
gopls
sqlite
gotools libsodium
cobra-cli
age
sqlite sqlite
unstable.odin unstable.odin
unstable.ols unstable.ols
# Build tools # Build tools
age
unstable.cargo
zip zip
opencode
# IDE # IDE
unstable.helix unstable.helix
typescript-language-server typescript-language-server

41
go.mod
View File

@@ -1,41 +0,0 @@
module github.com/sbrow/envr
go 1.24.6
require (
filippo.io/age v1.2.1
github.com/AlecAivazis/survey/v2 v2.3.7
github.com/mattn/go-isatty v0.0.20
github.com/olekukonko/tablewriter v1.1.0
github.com/spf13/cobra v1.10.1
modernc.org/sqlite v1.39.1
)
require (
filippo.io/edwards25519 v1.1.0 // indirect
github.com/cpuguy83/go-md2man/v2 v2.0.6 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/fatih/color v1.15.0 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 // indirect
github.com/mattn/go-colorable v0.1.14 // indirect
github.com/mattn/go-runewidth v0.0.16 // indirect
github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d // indirect
github.com/ncruces/go-strftime v0.1.9 // indirect
github.com/olekukonko/errors v1.1.0 // indirect
github.com/olekukonko/ll v0.0.9 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/rivo/uniseg v0.2.0 // indirect
github.com/russross/blackfriday/v2 v2.1.0 // indirect
github.com/spf13/pflag v1.0.9 // indirect
golang.org/x/crypto v0.24.0 // indirect
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b // indirect
golang.org/x/sys v0.37.0 // indirect
golang.org/x/term v0.36.0 // indirect
golang.org/x/text v0.30.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
modernc.org/libc v1.66.10 // indirect
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.11.0 // indirect
)

138
go.sum
View File

@@ -1,138 +0,0 @@
c2sp.org/CCTV/age v0.0.0-20240306222714-3ec4d716e805 h1:u2qwJeEvnypw+OCPUHmoZE3IqwfuN5kgDfo5MLzpNM0=
c2sp.org/CCTV/age v0.0.0-20240306222714-3ec4d716e805/go.mod h1:FomMrUJ2Lxt5jCLmZkG3FHa72zUprnhd3v/Z18Snm4w=
filippo.io/age v1.2.1 h1:X0TZjehAZylOIj4DubWYU1vWQxv9bJpo+Uu2/LGhi1o=
filippo.io/age v1.2.1/go.mod h1:JL9ew2lTN+Pyft4RiNGguFfOpewKwSHm5ayKD/A4004=
filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
github.com/AlecAivazis/survey/v2 v2.3.7 h1:6I/u8FvytdGsgonrYsVn2t8t4QiRnh6QSTqkkhIiSjQ=
github.com/AlecAivazis/survey/v2 v2.3.7/go.mod h1:xUTIdE4KCOIjsBAE1JYsUPoCqYdZ1reCfTwbto0Fduo=
github.com/Netflix/go-expect v0.0.0-20220104043353-73e0943537d2 h1:+vx7roKuyA63nhn5WAunQHLTznkw5W8b1Xc0dNjp83s=
github.com/Netflix/go-expect v0.0.0-20220104043353-73e0943537d2/go.mod h1:HBCaDeC1lPdgDeDbhX8XFpy1jqjK0IBG8W5K+xYqA0w=
github.com/cpuguy83/go-md2man/v2 v2.0.6 h1:XJtiaUW6dEEqVuZiMTn1ldk455QWwEIsMIJlo5vtkx0=
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
github.com/creack/pty v1.1.17 h1:QeVUsEDNrLBW4tMgZHvxy18sKtr6VI492kBhUfhDJNI=
github.com/creack/pty v1.1.17/go.mod h1:MOBLtS5ELjhRRrroQr9kyvTxUAFNvYEK993ew/Vr4O4=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/fatih/color v1.15.0 h1:kOqh6YHBtK8aywxGerMG2Eq3H6Qgoqeo13Bk2Mv/nBs=
github.com/fatih/color v1.15.0/go.mod h1:0h5ZqXfHYED7Bhv2ZJamyIOUej9KtShiJESRwBDUSsw=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/hinshun/vt10x v0.0.0-20220119200601-820417d04eec h1:qv2VnGeEQHchGaZ/u7lxST/RaJw+cv273q79D81Xbog=
github.com/hinshun/vt10x v0.0.0-20220119200601-820417d04eec/go.mod h1:Q48J4R4DvxnHolD5P8pOtXigYlRuPLGl6moFx3ulM68=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 h1:Z9n2FFNUXsshfwJMBgNA0RU6/i7WVaAegv3PtuIHPMs=
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51/go.mod h1:CzGEWj7cYgsdH8dAjBGEr58BoE7ScuLd+fwFZ44+/x8=
github.com/mattn/go-colorable v0.1.2/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE=
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
github.com/mattn/go-isatty v0.0.8/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=
github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
github.com/mgutz/ansi v0.0.0-20170206155736-9520e82c474b/go.mod h1:01TrycV0kFyexm33Z7vhZRXopbI8J3TDReVlkTgMUxE=
github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d h1:5PJl274Y63IEHC+7izoQE9x6ikvDFZS2mDVS3drnohI=
github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d/go.mod h1:01TrycV0kFyexm33Z7vhZRXopbI8J3TDReVlkTgMUxE=
github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/olekukonko/errors v1.1.0 h1:RNuGIh15QdDenh+hNvKrJkmxxjV4hcS50Db478Ou5sM=
github.com/olekukonko/errors v1.1.0/go.mod h1:ppzxA5jBKcO1vIpCXQ9ZqgDh8iwODz6OXIGKU8r5m4Y=
github.com/olekukonko/ll v0.0.9 h1:Y+1YqDfVkqMWuEQMclsF9HUR5+a82+dxJuL1HHSRpxI=
github.com/olekukonko/ll v0.0.9/go.mod h1:En+sEW0JNETl26+K8eZ6/W4UQ7CYSrrgg/EdIYT2H8g=
github.com/olekukonko/tablewriter v1.1.0 h1:N0LHrshF4T39KvI96fn6GT8HEjXRXYNDrDjKFDB7RIY=
github.com/olekukonko/tablewriter v1.1.0/go.mod h1:5c+EBPeSqvXnLLgkm9isDdzR3wjfBkHR9Nhfp3NWrzo=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY=
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/spf13/cobra v1.10.1 h1:lJeBwCfmrnXthfAupyUTzJ/J4Nc1RsHC/mSRU2dll/s=
github.com/spf13/cobra v1.10.1/go.mod h1:7SmJGaTHFVBY0jW4NXGluQoLvhqFQM+6XSKD+P4XaB0=
github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY=
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.6.1 h1:hDPOHmpOpP40lSULcqw7IrRb/u7w6RpDC9399XyoNd0=
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.24.0 h1:mnl8DM0o513X8fdIkmyFE/5hTYxbwYOjDS/+rK6qpRI=
golang.org/x/crypto v0.24.0/go.mod h1:Z1PMYSOR5nyMcyAVAIQSKCDwalqy85Aqn1x3Ws4L5DM=
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b h1:M2rDM6z3Fhozi9O7NWsxAkg/yqS/lQJ6PmkyIV3YP+o=
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b/go.mod h1:3//PLf8L/X+8b4vuAfHzxeRUl04Adcb341+IGKfnqS8=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.28.0 h1:gQBtGhjxykdjY9YhZpSlZIsbnaE2+PgjfLWUQTnoZ1U=
golang.org/x/mod v0.28.0/go.mod h1:yfB/L0NOf/kmEbXjzCPOx1iK1fRutOydrCMsqRhEBxI=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug=
golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ=
golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.36.0 h1:zMPR+aF8gfksFprF/Nc/rd1wRS1EI6nDBGyWAvDzx2Q=
golang.org/x/term v0.36.0/go.mod h1:Qu394IJq6V6dCBRgwqshf3mPF85AqzYEzofzRdZkWss=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.30.0 h1:yznKA/E9zq54KzlzBEAWn1NXSQ8DIp/NYMy88xJjl4k=
golang.org/x/text v0.30.0/go.mod h1:yDdHFIX9t+tORqspjENWgzaCVXgk0yYnYuSZ8UzzBVM=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/tools v0.37.0 h1:DVSRzp7FwePZW356yEAChSdNcQo6Nsp+fex1SUW09lE=
golang.org/x/tools v0.37.0/go.mod h1:MBN5QPQtLMHVdvsbtarmTNukZDdgwdwlO5qGacAzF0w=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
modernc.org/cc/v4 v4.26.5 h1:xM3bX7Mve6G8K8b+T11ReenJOT+BmVqQj0FY5T4+5Y4=
modernc.org/cc/v4 v4.26.5/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
modernc.org/ccgo/v4 v4.28.1 h1:wPKYn5EC/mYTqBO373jKjvX2n+3+aK7+sICCv4Fjy1A=
modernc.org/ccgo/v4 v4.28.1/go.mod h1:uD+4RnfrVgE6ec9NGguUNdhqzNIeeomeXf6CL0GTE5Q=
modernc.org/fileutil v1.3.40 h1:ZGMswMNc9JOCrcrakF1HrvmergNLAmxOPjizirpfqBA=
modernc.org/fileutil v1.3.40/go.mod h1:HxmghZSZVAz/LXcMNwZPA/DRrQZEVP9VX0V4LQGQFOc=
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
modernc.org/libc v1.66.10 h1:yZkb3YeLx4oynyR+iUsXsybsX4Ubx7MQlSYEw4yj59A=
modernc.org/libc v1.66.10/go.mod h1:8vGSEwvoUoltr4dlywvHqjtAqHBaw0j1jI7iFBTAr2I=
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8=
modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
modernc.org/sqlite v1.39.1 h1:H+/wGFzuSCIEVCvXYVHX5RQglwhMOvtHSv+VtidL2r4=
modernc.org/sqlite v1.39.1/go.mod h1:9fjQZ0mB1LLP0GYrp39oOJXx/I2sxEnZtzCmEQIKvGE=
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=

View File

@@ -1,58 +0,0 @@
package main
import (
"flag"
"fmt"
"log"
"os"
"path/filepath"
"strings"
"github.com/sbrow/envr/cmd" // update to your module path
"github.com/spf13/cobra/doc"
)
func main() {
out := flag.String("out", "./docs/cli", "output directory")
format := flag.String("format", "markdown", "markdown|man|rest")
front := flag.Bool("frontmatter", false, "prepend simple YAML front matter to markdown")
flag.Parse()
if err := os.MkdirAll(*out, 0o755); err != nil {
log.Fatal(err)
}
root := cmd.Root()
root.DisableAutoGenTag = true // stable, reproducible files (no timestamp footer)
switch *format {
case "markdown":
if *front {
prep := func(filename string) string {
base := filepath.Base(filename)
name := strings.TrimSuffix(base, filepath.Ext(base))
title := strings.ReplaceAll(name, "_", " ")
return fmt.Sprintf("---\ntitle: %q\nslug: %q\ndescription: \"CLI reference for %s\"\n---\n\n", title, name, title)
}
link := func(name string) string { return strings.ToLower(name) }
if err := doc.GenMarkdownTreeCustom(root, *out, prep, link); err != nil {
log.Fatal(err)
}
} else {
if err := doc.GenMarkdownTree(root, *out); err != nil {
log.Fatal(err)
}
}
case "man":
hdr := &doc.GenManHeader{Title: strings.ToUpper(root.Name()), Section: "1"}
if err := doc.GenManTree(root, hdr, *out); err != nil {
log.Fatal(err)
}
case "rest":
if err := doc.GenReSTTree(root, *out); err != nil {
log.Fatal(err)
}
default:
log.Fatalf("unknown format: %s", *format)
}
}

View File

@@ -1,7 +0,0 @@
package main
import "github.com/sbrow/envr/cmd"
func main() {
cmd.Execute()
}

44
main.odin.bak Normal file
View File

@@ -0,0 +1,44 @@
package main
import "core:fmt"
import "core:os"
main :: proc() {
cmd, ok := parse_args()
if !ok {
return
}
switch cmd.name {
case "init":
cmd_init(&cmd)
case "version":
cmd_version(&cmd)
case "deps":
cmd_deps(&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.printf("Unknown command: %s\n", cmd.name)
print_usage()
os.exit(1)
}
}

View File

@@ -4,13 +4,14 @@ import "core:fmt"
import "core:os" import "core:os"
import "core:strings" import "core:strings"
import "core:sync" import "core:sync"
import "core:terminal"
fd_counter: sync.Atomic_Mutex fd_counter: sync.Atomic_Mutex
fd_seq: int fd_seq: int
// Caller is responsible for freeing paths // Caller is responsible for freeing paths
scan_path :: proc(search_path: string, cfg: Config) -> (paths: [dynamic]string, ok: bool) { scan_path :: proc(search_path: string, cfg: Config) -> (paths: [dynamic]string, ok: bool) {
if is_tty() { if terminal.is_terminal(os.stdout) {
fmt.printf("Searching for all files in \"%s\"...\n", search_path) fmt.printf("Searching for all files in \"%s\"...\n", search_path)
} }
all_files, all_ok := run_fd(build_fd_args(search_path, cfg, true)) all_files, all_ok := run_fd(build_fd_args(search_path, cfg, true))
@@ -18,7 +19,7 @@ scan_path :: proc(search_path: string, cfg: Config) -> (paths: [dynamic]string,
return return
} }
if is_tty() { if terminal.is_terminal(os.stdout) {
fmt.printf("Search for unignored fies in \"%s\"...\n", search_path) fmt.printf("Search for unignored fies in \"%s\"...\n", search_path)
} }
unignored_files, unignored_ok := run_fd(build_fd_args(search_path, cfg, false)) unignored_files, unignored_ok := run_fd(build_fd_args(search_path, cfg, false))

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) ---
}

View File

@@ -1,226 +0,0 @@
const std = @import("std");
db_path: []const u8 = "~/.envr/data.age",
/// Keys that are available for encryption
keys: []const SSHKeyPair = &.{
.from_pub_path("~/.ssh/id_ed25519.pub"),
},
/// Rules for how to match the scan command
scan: ScanConfig = .default,
// TODO: Allow incomplete pairs
pub const SSHKeyPair = struct {
private: []const u8,
public: []const u8,
/// Caller owns the returned memory
pub fn from_path(
gpa: std.mem.Allocator,
path: []const u8,
) error{OutOfMemory}!SSHKeyPair {
if (std.mem.eql(u8, std.fs.path.extension(path), ".pub")) {
return from_pub_path(path);
} else {
return .{
.public = try std.mem.concat(gpa, u8, &.{ path, ".pub" }),
.private = path,
};
}
}
pub fn from_pub_path(path: []const u8) SSHKeyPair {
std.debug.assert(std.mem.eql(u8, std.fs.path.extension(path), ".pub"));
return .{
.public = path,
.private = path[0 .. path.len - 4],
};
}
};
/// Configuration for the scan command
pub const ScanConfig = struct {
/// the file extension to look for
matcher: []const u8,
/// Glob patterns to ignore
exclude: []const []const u8,
/// paths to search in
include: []const []const u8,
const default: @This() = .{
.matcher = "\\.env",
.exclude = &.{
"*\\.envrc",
"\\.local",
"node_modules",
"vendor",
},
.include = &.{"~"},
};
};
/// Load the Config from the file at path
/// TODO: Use a concrete error set
pub fn load(
io: std.Io,
gpa: std.mem.Allocator,
path: []const u8,
) !std.json.Parsed(@This()) {
var file = try std.Io.Dir.cwd().openFile(
io,
path,
.{ .mode = .read_only },
);
defer file.close(io);
var buffer: [4096]u8 = undefined;
var reader = file.reader(io, &buffer);
var json_reader: std.json.Reader = .init(gpa, &reader.interface);
defer json_reader.deinit();
return try std.json.parseFromTokenSource(
@This(),
gpa,
&json_reader,
.{},
);
}
/// Save the config to the given file
pub fn save(
self: *@This(),
io: std.Io,
dir: std.Io.Dir,
path: []const u8,
) !void {
// TODO: Remove dependence on string?
var string: std.Io.Writer.Allocating = .init(std.testing.allocator);
defer string.deinit();
try string.writer.print(
"{f}",
.{std.json.fmt(self, .{ .whitespace = .indent_2 })},
);
var file = try dir.createFile(io, path, .{ .truncate = true });
defer file.close(io);
try file.writeStreamingAll(io, string.written());
}
test "loading the default config from disk matches expected values" {
const gpa = std.testing.allocator;
const parsed = try load(std.testing.io, gpa, "./fixtures/default_config.json");
defer parsed.deinit();
const got = parsed.value;
try std.testing.expectEqualDeep(got.scan, ScanConfig.default);
}
test "saving to a new file upserts the file" {
const io = std.testing.io;
var cfg: @This() = .{};
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
var dir = tmp.dir;
try std.testing.expectError(
error.FileNotFound,
dir.statFile(io, "config.json", .{}),
);
try cfg.save(io, dir, "config.json");
const contents = try dir.readFileAlloc(
io,
"config.json",
std.testing.allocator,
.unlimited,
);
defer std.testing.allocator.free(contents);
const want =
\\{
\\ "db_path": "~/.envr/data.age",
\\ "keys": [
\\ {
\\ "private": "~/.ssh/id_ed25519",
\\ "public": "~/.ssh/id_ed25519.pub"
\\ }
\\ ],
\\ "scan": {
\\ "matcher": "\\.env",
\\ "exclude": [
\\ "*\\.envrc",
\\ "\\.local",
\\ "node_modules",
\\ "vendor"
\\ ],
\\ "include": [
\\ "~"
\\ ]
\\ }
\\}
;
try std.testing.expectEqualSlices(u8, want, contents);
}
test "saving to an existing file updates the file" {
const io = std.testing.io;
var cfg: @This() = .{};
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
var dir = tmp.dir;
try dir.writeFile(io, .{ .sub_path = "config.json", .data = "{}" });
_ = try dir.statFile(io, "config.json", .{});
try cfg.save(io, dir, "config.json");
const contents = try dir.readFileAlloc(
io,
"config.json",
std.testing.allocator,
.unlimited,
);
defer std.testing.allocator.free(contents);
const want =
\\{
\\ "db_path": "~/.envr/data.age",
\\ "keys": [
\\ {
\\ "private": "~/.ssh/id_ed25519",
\\ "public": "~/.ssh/id_ed25519.pub"
\\ }
\\ ],
\\ "scan": {
\\ "matcher": "\\.env",
\\ "exclude": [
\\ "*\\.envrc",
\\ "\\.local",
\\ "node_modules",
\\ "vendor"
\\ ],
\\ "include": [
\\ "~"
\\ ]
\\ }
\\}
;
try std.testing.expectEqualSlices(u8, want, contents);
}

View File

@@ -1,488 +0,0 @@
//! Db interacts with an age encrypted sqlite database.
//!
const std = @import("std");
const sqlite = @import("sqlite");
const age = @import("age.zig");
const Config = @import("Config.zig");
/// controls the keys and filepaths used for saving
opts: OpenOptions,
/// The underlying data store.
sql_db: sqlite.Db,
/// Set to true whenever the data updates. If false when close() is called,
/// the database will be closed without saving
changed: bool = false,
/// Decrypts the database into a temporary file and opens it in memory
// FIXME: Test me with real file
pub fn open(
io: std.Io,
gpa: std.mem.Allocator,
opts: OpenOptions,
) !@This() {
// FIXME: cheating here
const db_path = try std.fs.path.join(gpa, &.{
opts.home,
opts.config.db_path[2..],
});
defer gpa.free(db_path);
// const tmp_dir = try std.Io.Dir.cwd().openDir(io, tmp, .{});
// defer tmp_dir.deleteFile(io, "envr.db");
const tmp_db_path = try std.fs.path.joinZ(gpa, &.{ opts.tmp, "envr.db" });
defer gpa.free(tmp_db_path);
if (db_exists(io, db_path)) {
// TODO: Use std.MultiArrayList? Had json issues
{
var private_keys: std.ArrayList([]const u8) = try .initCapacity(
gpa,
opts.config.keys.len,
);
defer private_keys.deinit(gpa);
for (opts.config.keys) |key| {
// FIXME: cheating here
if (std.mem.startsWith(u8, key.private, "~/")) {
const key_path = try std.fs.path.join(gpa, &.{
opts.home,
key.private[2..],
});
private_keys.appendAssumeCapacity(key_path);
// defer gpa.free(key_path);
} else {
private_keys.appendAssumeCapacity(key.private);
}
}
// TODO: Pass key(s) from Config
try age.decrypt(io, gpa, private_keys.items, db_path, tmp_db_path);
for (opts.config.keys, 0..) |key, i| {
if (std.mem.startsWith(u8, key.private, "~/")) {
gpa.free(private_keys.items[i]);
}
}
}
}
return open_decrypted(opts, tmp_db_path);
}
const OpenOptions = struct {
config: Config = .{},
/// The path to the home directory
home: []const u8 = "~/",
/// The path to the /tmp directory
// FIXME: Support windows
tmp: []const u8 = "/tmp",
};
/// Create a new instance of the database
fn open_decrypted(opts: OpenOptions, tmp_db_path: [:0]const u8) !@This() {
var db = try sqlite.Db.init(.{
.mode = .{ .File = tmp_db_path },
.open_flags = .{
.write = true,
.create = true,
},
.threading_mode = .MultiThread,
});
try db.exec(
\\create table if not exists envr_env_files (
\\ path text primary key not null
\\, remotes text -- JSON
\\, sha256 text not null
\\, contents text not null
\\)
, .{}, .{});
return .{
.sql_db = db,
.opts = opts,
};
}
/// Returns true if a file exists at ~/.envr/data.age
fn db_exists(io: std.Io, path: []const u8) bool {
if (std.Io.Dir.cwd().access(io, path, .{ .read = true })) {
return true;
} else |_| {
return false;
}
}
// TODO: Finish
// pub fn tmpDir(opts: std.fs.Dir.OpenDirOptions) TmpDir {
// var random_bytes: [TmpDir.random_bytes_count]u8 = undefined;
// std.crypto.random.bytes(&random_bytes);
// var sub_path: [TmpDir.sub_path_len]u8 = undefined;
// _ = std.fs.base64_encoder.encode(&sub_path, &random_bytes);
// }
//
// const TmpDir = struct {};
/// Close the database
/// FIXME: Test me with data but no changes
/// FIXME: Test me with data and changes
pub fn close(
self: *@This(),
io: std.Io,
gpa: std.mem.Allocator,
) !void {
defer self.sql_db.deinit();
if (self.changed) {
const tmp_db_path = try std.fs.path.join(gpa, &.{ self.opts.tmp, "envr.db" });
defer gpa.free(tmp_db_path);
try self.sql_db.exec("VACUUM INTO ?", .{}, .{tmp_db_path});
const db_path = try std.fs.path.join(gpa, &.{ self.opts.home, ".envr", "data.age" });
defer gpa.free(db_path);
{
// TODO: Use std.MultiArrayList? Had json issues
var public_keys: std.ArrayList([]const u8) = try .initCapacity(
gpa,
self.opts.config.keys.len,
);
defer public_keys.deinit(gpa);
for (self.opts.config.keys) |key| {
public_keys.appendAssumeCapacity(key.private);
}
try age.encrypt(io, gpa, public_keys.items, tmp_db_path, db_path);
}
self.changed = false;
}
}
/// Returns a list of all the .env files present in the database.
/// The caller is responsible for freeing memory
pub fn list(self: *@This(), gpa: std.mem.Allocator) ![]EnvFile {
var stmt = try self.sql_db.prepare(
"select path, remotes, sha256, contents from envr_env_files",
);
defer stmt.deinit();
return stmt.all(EnvFile, gpa, .{}, .{});
}
pub const EnvFile = struct {
// TODO: Should use file_name in the struct and derive from the path.
path: []const u8,
// /// dir is derived from Path, and is not stored in the database.
// dir: []const u8,
/// JSON encoded list of strings
remotes: []const u8,
sha256: []const u8,
contents: []const u8,
pub fn deinit(self: *EnvFile, alloc: std.mem.Allocator) void {
alloc.free(self.path);
alloc.free(self.remotes);
alloc.free(self.sha256);
alloc.free(self.contents);
}
};
test {
std.testing.refAllDecls(@import("age.zig"));
}
test "simple database can be opened" {
var db = try sqlite.Db.init(.{
.mode = sqlite.Db.Mode{ .File = "./fixtures/example.db" },
.open_flags = .{
.write = false,
.create = false,
},
.threading_mode = .MultiThread,
});
var stmt = try db.prepare("SELECT * FROM hello");
defer stmt.deinit();
const alloc = std.testing.allocator;
if (try stmt.oneAlloc(struct { text: []const u8 }, alloc, .{}, .{})) |got| {
defer alloc.free(got.text);
try std.testing.expectEqualSlices(u8, "world!", got.text);
} else {
return error.TestUnexpectedResult;
}
}
test "encrypted database can be opened" {
const io = std.testing.io;
const gpa = std.testing.allocator;
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
const dir_path = try tmp.dir.realPathFileAlloc(io, ".", gpa);
defer gpa.free(dir_path);
const decrypted_path = try std.fs.path.joinZ(gpa, &.{ dir_path, "example.db" });
defer gpa.free(decrypted_path);
try age.decrypt(
io,
gpa,
&.{"./fixtures/insecure-test-key"},
"./fixtures/encrypted-example.db.age",
decrypted_path,
);
var db = try sqlite.Db.init(.{
.mode = sqlite.Db.Mode{ .File = decrypted_path },
.open_flags = .{
.write = false,
.create = false,
},
.threading_mode = .MultiThread,
});
var stmt = try db.prepare("SELECT * FROM hello");
defer stmt.deinit();
const alloc = std.testing.allocator;
if (try stmt.oneAlloc(struct { text: []const u8 }, alloc, .{}, .{})) |got| {
defer alloc.free(got.text);
try std.testing.expectEqualSlices(u8, "world!", got.text);
} else {
return error.TestUnexpectedResult;
}
}
test "Closing a fresh database does not create a file" {
const io = std.testing.io;
const gpa = std.testing.allocator;
var tmp_dir = std.testing.tmpDir(.{});
defer tmp_dir.cleanup();
try tmp_dir.dir.createDir(io, "home", .default_dir);
try tmp_dir.dir.createDir(io, "tmp", .default_dir);
const tmp_dir_path = try tmp_dir.dir.realPathFileAlloc(io, ".", gpa);
defer gpa.free(tmp_dir_path);
const home = try std.fs.path.join(gpa, &.{ tmp_dir_path, "home" });
defer gpa.free(home);
const tmp = try std.fs.path.join(gpa, &.{ tmp_dir_path, "tmp" });
defer gpa.free(tmp);
// TODO: Pass testing keys
var db: @This() = try .open(io, gpa, .{ .home = home, .tmp = tmp });
// TODO: Get rid of direct access
const db_path = try std.fs.path.join(gpa, &.{ home, ".envr", "data.age" });
defer gpa.free(db_path);
try std.testing.expectError(
error.FileNotFound,
tmp_dir.dir.access(io, db_path, .{ .read = true }),
);
try db.close(io, gpa);
try std.testing.expectError(
error.FileNotFound,
tmp_dir.dir.access(io, db_path, .{ .read = true }),
);
}
test "single-file.db has envr_env_files table" {
const io = std.testing.io;
const gpa = std.testing.allocator;
const dir_path = try std.Io.Dir.cwd().realPathFileAlloc(io, ".", gpa);
defer gpa.free(dir_path);
const path = try std.fs.path.joinZ(
gpa,
&.{ dir_path, "fixtures", "single-file.db" },
);
defer gpa.free(path);
var db = try sqlite.Db.init(.{
.mode = .{ .File = path },
.open_flags = .{
.write = false,
.create = false,
},
.threading_mode = .MultiThread,
});
var diags: sqlite.Diagnostics = .{};
var stmt = db.prepareDynamicWithDiags(
"select name from sqlite_master where type='table'",
.{ .diags = &diags },
) catch |err| {
std.log.err(
"unable to prepare statement, got error {}. diagnostics: {f}",
.{ err, diags },
);
return err;
};
defer stmt.deinit();
const tables = (try stmt.oneAlloc(
[]const u8,
gpa,
.{ .diags = &diags },
.{},
)).?;
defer gpa.free(tables);
try std.testing.expectEqualSlices(u8, "envr_env_files", tables);
}
// test "raw restore works" {
// const io = std.testing.io;
// const gpa = std.testing.allocator;
// var db = try sqlite.Db.init(.{
// .mode = .Memory,
// .open_flags = .{
// .write = true,
// .create = true,
// },
// .threading_mode = .MultiThread,
// });
// try db.exec(
// \\create table envr_env_files (
// \\ path text primary key not null
// \\, remotes text -- JSON
// \\, sha256 text not null
// \\, contents text not null
// \\)
// , .{}, .{});
// const dir_path = try std.Io.Dir.cwd().realPathFileAlloc(io, ".", gpa);
// defer gpa.free(dir_path);
// const path = try std.fs.path.join(
// gpa,
// &.{ dir_path, "fixtures", "single-file.db" },
// );
// defer gpa.free(path);
// std.debug.print("path: {s}\n", .{path});
// try db.exec(
// "ATTACH DATABASE ? AS source",
// .{},
// .{path},
// );
// defer db.exec("DETACH DATABASE source", .{}, .{}) catch unreachable;
// var diags: sqlite.Diagnostics = .{};
// db.exec(
// "INSERT INTO main.envr_env_files SELECT * FROM source.envr_env_files",
// .{ .diags = &diags },
// .{},
// ) catch |err| {
// std.log.err(
// "unable to prepare statement, got error {}. diagnostics: {f}",
// .{ err, diags },
// );
// return err;
// };
// }
// test "Closing a modified database does create a file" {}
test "list displays the database's keys" {
const io = std.testing.io;
const gpa = std.testing.allocator;
var tmp_dir = std.testing.tmpDir(.{});
defer tmp_dir.cleanup();
try tmp_dir.dir.createDir(io, "home", .default_dir);
try tmp_dir.dir.createDir(io, "home/.envr", .default_dir);
try tmp_dir.dir.createDir(io, "tmp", .default_dir);
const tmp_dir_path = try tmp_dir.dir.realPathFileAlloc(io, ".", gpa);
defer gpa.free(tmp_dir_path);
const home = try std.fs.path.join(gpa, &.{ tmp_dir_path, "home" });
defer gpa.free(home);
const tmp = try std.fs.path.join(gpa, &.{ tmp_dir_path, "tmp" });
defer gpa.free(tmp);
// TODO: Get rid of direct access
const db_path = try std.fs.path.join(gpa, &.{ home, ".envr", "data.age" });
defer gpa.free(db_path);
try std.Io.Dir.cwd().copyFile(
"fixtures/encrypted-single-file.db.age",
tmp_dir.dir,
"home/.envr/data.age",
io,
.{},
);
// Asserts file existence
try tmp_dir.dir.access(io, db_path, .{ .read = true });
// TODO: Pass testing keys
const config: Config = .{
.keys = &.{.from_pub_path("fixtures/insecure-test-key.pub")},
};
var db: @This() = try .open(io, gpa, .{
.config = config,
.home = home,
.tmp = tmp,
});
const env_files = try db.list(gpa);
defer gpa.free(env_files);
try std.testing.expectEqual(1, env_files.len);
var hasher = std.crypto.hash.sha2.Sha256.init(.{});
try std.testing.expectEqual(1, env_files.len);
for (env_files) |*file| {
defer file.deinit(gpa);
try std.testing.expectEqualSlices(
u8,
"~/project/.env.example",
file.path,
);
try std.testing.expectEqualSlices(
u8,
"API_KEY=\\\"sk_my_api_key\\\"\\nAPP_ENV=testing",
file.contents,
);
try std.testing.expectEqualSlices(
u8,
"[\"git@github.com:user/project.git\"]",
file.remotes,
);
hasher.update(file.contents);
const hash = hasher.finalResult();
try std.testing.expectEqualStrings(&std.fmt.bytesToHex(&hash, .lower), file.sha256);
}
try db.close(io, gpa);
}

View File

@@ -1,153 +0,0 @@
const std = @import("std");
/// Decrypts the file into output path
pub fn decrypt(
io: std.Io,
gpa: std.mem.Allocator,
private_keys: []const []const u8,
input_path: []const u8,
output_path: []const u8,
) !void {
// TODO: use raw array?
var argv: std.ArrayList([]const u8) = try .initCapacity(gpa, 2 + (2 * private_keys.len) + 3);
defer argv.deinit(gpa);
argv.appendAssumeCapacity("age");
argv.appendAssumeCapacity("-d");
for (private_keys) |key| {
argv.appendAssumeCapacity("-i");
argv.appendAssumeCapacity(key);
}
argv.appendAssumeCapacity("-o");
argv.appendAssumeCapacity(output_path);
argv.appendAssumeCapacity(input_path);
const result = try std.process.run(gpa, io, .{
.argv = argv.items,
});
defer gpa.free(result.stderr);
defer gpa.free(result.stdout);
if (result.stdout.len > 0) {
std.debug.print("stdout: \"{s}\"\n", .{result.stdout});
unreachable;
}
if (result.stderr.len > 0) {
std.debug.print("stderr: \"{s}\"\n", .{result.stderr});
unreachable;
}
}
/// Encrypts the file into output path
pub fn encrypt(
io: std.Io,
gpa: std.mem.Allocator,
// TODO: Accept multiple keys
public_keys: []const []const u8,
input_path: []const u8,
output_path: []const u8,
) !void {
var argv: std.ArrayList([]const u8) = try .initCapacity(gpa, 2 + (2 * public_keys.len) + 3);
defer argv.deinit(gpa);
argv.appendAssumeCapacity("age");
argv.appendAssumeCapacity("-e");
for (public_keys) |key| {
argv.appendAssumeCapacity("-R");
argv.appendAssumeCapacity(key);
}
argv.appendAssumeCapacity("-o");
argv.appendAssumeCapacity(output_path);
argv.appendAssumeCapacity(input_path);
const result = try std.process.run(gpa, io, .{
.argv = argv.items,
});
defer gpa.free(result.stderr);
defer gpa.free(result.stdout);
if (result.stdout.len > 0) {
std.debug.print("stdout: \"{s}\"\n", .{result.stdout});
unreachable;
}
if (result.stderr.len > 0) {
std.debug.print("stderr: \"{s}\"\n", .{result.stderr});
unreachable;
}
}
test "sample file can be decrypted" {
const io = std.testing.io;
const gpa = std.testing.allocator;
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
const dir_path = try tmp.dir.realPathFileAlloc(io, ".", gpa);
defer gpa.free(dir_path);
const output_path = try std.fs.path.join(gpa, &.{ dir_path, "got.txt" });
defer gpa.free(output_path);
try decrypt(
io,
gpa,
&.{"./fixtures/insecure-test-key"},
"./fixtures/hello-world.age",
output_path,
);
const contents = try tmp.dir.readFileAlloc(io, output_path, gpa, .unlimited);
defer gpa.free(contents);
try std.testing.expectEqualSlices(u8, "Hello, World!\n", contents);
}
test "sample file can be encrypted" {
const io = std.testing.io;
const gpa = std.testing.allocator;
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
const dir_path = try tmp.dir.realPathFileAlloc(io, ".", gpa);
defer gpa.free(dir_path);
const output_path = try std.fs.path.join(gpa, &.{ dir_path, "hello-world.age" });
defer gpa.free(output_path);
try encrypt(
io,
gpa,
&.{"./fixtures/insecure-test-key.pub"},
"./fixtures/hello-world.txt",
output_path,
);
const got = try tmp.dir.readFileAlloc(io, output_path, gpa, .unlimited);
defer gpa.free(got);
const want = try std.Io.Dir.cwd().readFileAlloc(
io,
"./fixtures/hello-world.age",
gpa,
.unlimited,
);
defer gpa.free(want);
const contents = try tmp.dir.readFileAlloc(io, output_path, gpa, .unlimited);
defer gpa.free(contents);
try std.testing.expectEqual(want.len, got.len);
// FIXME: Test that decrypted file contents match
// try std.testing.expectEqualSlices(u8, "Hello, World!\n", decrypted_contents);
}

View File

@@ -1,146 +0,0 @@
//! By convention, root.zig is the root source file when making a package.
const std = @import("std");
const Io = std.Io;
pub const Command = struct {
name: []const u8,
short: ?[]const u8 = null,
long: ?[]const u8 = null,
subcommands: []const Command = &.{},
examples: [][]const u8 = &.{},
/// The enum type of the command
Type: type,
/// The type of struct that holds the Commands's flags and arguments
// Params: type,
pub fn new(cmd: CommandOptions) Command {
const subcommands: [cmd.subcommands.len]Command = blk: {
var result: [cmd.subcommands.len]Command = undefined;
inline for (cmd.subcommands, 0..) |sub, idx| {
result[idx] = new(sub);
}
break :blk result;
};
return .{
.name = cmd.name,
.short = cmd.short,
.long = cmd.long,
.subcommands = &subcommands,
.Type = cmd.as_enum(),
};
}
pub fn parse(comptime self: @This(), args: []const []const u8) self.Type {
if (args.len == 0) {
return @enumFromInt(0);
}
const target = args[0];
inline for (self.subcommands, 1..) |cmd, idx| {
if (std.mem.eql(u8, target, cmd.name)) {
return @enumFromInt(idx);
}
}
return @enumFromInt(self.subcommands.len + 1);
}
/// Used for indentation when printing command help
const tab = " ";
/// Print usage information to the console.
pub fn help(self: @This(), w: *Io.Writer) !void {
defer w.flush() catch {};
if (self.long) |long| {
try w.print("{s}\n\n", .{long});
}
try w.print("Usage:\n{s}{s}\n", .{ tab, self.name });
if (self.subcommands.len > 0) {
try w.print("\nAvailable Commands:\n", .{});
var max_width: u8 = 0;
inline for (self.subcommands) |cmd| {
max_width = @max(max_width, cmd.name.len);
}
// Print short command description
inline for (self.subcommands) |cmd| {
try w.print(
"{s}{s}",
.{
tab,
cmd.name,
},
);
for (0..(max_width - cmd.name.len)) |_| {
try w.print(" ", .{});
}
try w.print(
" {s}\n",
.{
cmd.short orelse "",
},
);
}
try w.print("\n", .{});
}
// TODO: Print flags
// TODO: Print arguments
if (self.subcommands.len > 0) {
try w.print(
"Use \"{s} [command] --help\" for more information about a command.",
.{self.name},
);
}
}
};
pub const ParseError = error{
InvalidType,
};
const CommandOptions = struct {
name: []const u8,
short: ?[]const u8 = null,
long: ?[]const u8 = null,
subcommands: []const CommandOptions = &[0]CommandOptions{},
fn as_enum(self: @This()) type {
var field_names: [self.subcommands.len + 2][]const u8 = undefined;
var field_values: [self.subcommands.len + 2]u32 = undefined;
field_names[0] = self.name;
field_values[0] = 0;
inline for (self.subcommands, 1..) |cmd, idx| {
field_names[idx] = cmd.name;
field_values[idx] = idx;
}
field_names[self.subcommands.len + 1] = "unknown";
field_values[self.subcommands.len + 1] = self.subcommands.len + 1;
return @Enum(
u32,
.exhaustive,
&field_names,
&field_values,
);
}
};
// /// parses the args into params
// pub fn params(cmd: Command, args: [][]const u8) cmd.Params {
// }

View File

@@ -1,149 +0,0 @@
const std = @import("std");
const Io = std.Io;
const config = @import("config");
const comma = @import("comma");
const envr = @import("envr");
const goBinary = "envr-go";
pub fn main(init: std.process.Init) !void {
// This is appropriate for anything that lives as long as the process.
const arena: std.mem.Allocator = init.arena.allocator();
const args = try init.minimal.args.toSlice(arena);
try run(init.environ_map, init.io, arena, args);
}
/// Attempt to run the requested command.
fn run(
environ_map: *std.process.Environ.Map,
io: Io,
arena: std.mem.Allocator,
args: []const [:0]const u8,
) !void {
const page_size = std.heap.pageSize();
const cmd = envr.root.parse(args[1..]);
switch (cmd) {
.envr => {
var stdout_buffer: [page_size]u8 = undefined;
var stdout_file_writer: Io.File.Writer = .init(.stdout(), io, &stdout_buffer);
const stdout_writer = &stdout_file_writer.interface;
return envr.root.help(stdout_writer);
},
.deps => {
var stdout_buffer: [1024]u8 = undefined;
var stdout_file_writer: Io.File.Writer = .init(.stdout(), io, &stdout_buffer);
const stdout_writer = &stdout_file_writer.interface;
return envr.deps(
io,
stdout_writer,
environ_map.get("PATH").?,
);
},
.init => {
var stdout_buffer: [1024]u8 = undefined;
var stdout_file_writer: Io.File.Writer = .init(.stdout(), io, &stdout_buffer);
const stdout_writer = &stdout_file_writer.interface;
try envr.init_cmd(
io,
arena,
stdout_writer,
environ_map.get("HOME").?,
.{
// TODO: Actually parse this
.force = true,
},
);
},
.list => {
var stdout_buffer: [page_size]u8 = undefined;
var stdout_file_writer: Io.File.Writer = .init(.stdout(), io, &stdout_buffer);
const stdout_writer = &stdout_file_writer.interface;
return envr.list(
io,
arena,
stdout_writer,
environ_map.get("HOME").?,
// TODO: Don't hardcode this?
"/tmp",
);
},
.version => {
var stdout_buffer: [1024]u8 = undefined;
var stdout_file_writer: Io.File.Writer = .init(.stdout(), io, &stdout_buffer);
const stdout_writer = &stdout_file_writer.interface;
return version(stdout_writer);
},
.unknown => {
return fallback_to_go(io, arena, args);
},
}
}
fn version(writer: *Io.Writer) !void {
try writer.print("{s}\n", .{config.version});
try writer.flush();
}
fn fallback_to_go(
io: Io,
arena: std.mem.Allocator,
args: []const [:0]const u8,
) std.process.ReplaceError {
// Remap args
var childArgs = try std.ArrayList([]const u8).initCapacity(arena, args.len);
childArgs.appendAssumeCapacity(goBinary);
for (args[1..]) |arg| {
childArgs.appendAssumeCapacity(arg);
}
return std.process.replace(io, .{ .argv = childArgs.items });
}
test "simple test" {
const gpa = std.testing.allocator;
var alist: std.ArrayList(i32) = .empty;
defer alist.deinit(gpa); // Try commenting this out and see if zig detects the memory leak!
try alist.append(gpa, 42);
try std.testing.expectEqual(@as(i32, 42), alist.pop());
}
test "fuzz example" {
try std.testing.fuzz({}, testOne, .{});
}
fn testOne(context: void, smith: *std.testing.Smith) !void {
_ = context;
// Try passing `--fuzz` to `zig build test` and see if it manages to fail this test case!
const gpa = std.testing.allocator;
var alist: std.ArrayList(u8) = .empty;
defer alist.deinit(gpa);
while (!smith.eos()) switch (smith.value(enum { add_data, dup_data })) {
.add_data => {
const slice = try alist.addManyAsSlice(gpa, smith.value(u4));
smith.bytes(slice);
},
.dup_data => {
if (alist.items.len == 0) continue;
if (alist.items.len > std.math.maxInt(u32)) return error.SkipZigTest;
const len = smith.valueRangeAtMost(u32, 1, @min(32, alist.items.len));
const off = smith.valueRangeAtMost(u32, 0, @intCast(alist.items.len - len));
try alist.appendSlice(gpa, alist.items[off..][0..len]);
try std.testing.expectEqualSlices(
u8,
alist.items[off..][0..len],
alist.items[alist.items.len - len ..],
);
},
};
}

View File

@@ -1,423 +0,0 @@
//! By convention, root.zig is the root source file when making a package.
const std = @import("std");
const Io = std.Io;
const Command = @import("comma").Command;
const Config = @import("Config.zig");
const Db = @import("Db.zig");
const tabula = @import("./tabula.zig");
pub const root: Command = .new(.{
.name = "envr",
.short = "Manage your .env files.",
.long =
\\envr keeps your .env synced to a local, age encrypted database.
\\It 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 <path to repository> .env
,
.subcommands = &.{
.{
.name = "deps",
.short = "Check for missing binaries",
.long =
\\envr relies on external binaries for certain functionality.
\\
\\ The deps command reports which binaries are available and which are not."
,
},
.{
.name = "init",
.short = "Set up envr",
.long =
\\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.
,
//.flags = struct { force: bool }
},
.{
.name = "list",
.short = "View your tracked files",
},
.{
.name = "version",
.short = "Show envr's version",
},
},
});
// Display dependency statuses
pub fn deps(
io: Io,
writer: *Io.Writer,
path: []const u8,
) !void {
const feats: Features = try .scan(io, path);
// FIXME: Draw as a table
try writer.print("features: {}", .{feats});
try writer.flush();
}
const Features = packed struct {
git: bool = false,
fd: bool = false,
const all_features: Features = .{
.git = true,
.fd = true,
};
/// Scans your PATH variable for programs.
pub fn scan(io: Io, path: []const u8) !@This() {
var feats: Features = .{};
var dirs = std.mem.splitScalar(u8, path, std.fs.path.delimiter);
loop: while (dirs.next()) |dir| {
const dirt = Io.Dir.openDir(Io.Dir.cwd(), io, dir, .{ .follow_symlinks = true, .iterate = true }) catch continue;
defer dirt.close(io);
var dir_paths = dirt.iterate();
while (try dir_paths.next(io)) |file| {
// FIXME: Check if executable
if (std.mem.eql(u8, std.fs.path.basename(file.name), "git")) {
feats.git = true;
if (feats == Features.all_features) {
break :loop;
}
}
if (std.mem.eql(u8, std.fs.path.basename(file.name), "fd")) {
feats.fd = true;
if (feats == Features.all_features) {
break :loop;
}
}
}
}
return feats;
}
};
pub fn init_cmd(
io: Io,
arena: std.mem.Allocator,
out: *std.Io.Writer,
home: []const u8,
flags: struct { force: bool },
) !void {
defer out.flush() catch unreachable;
// TODO: Don't hardcode
const cfgPath = try std.fs.path.join(arena, &.{ home, ".envr", "config.json" });
defer arena.free(cfgPath);
if (flags.force or !file_exists(io, cfgPath)) {
const keys = try select_ssh_keys(io, arena, home, out);
// defer {
// for (keys) |*key| {
// arena.destroy(key);
// }
// arena.free(&keys);
// }
// const cfg: Config = .{ .keys = keys };
// TODO: How to handle this error?
// try cfg.save(io, cfgPath);
try out.print(
"Config initialized with {} SSH key(s). You are ready to use envr.\n",
.{keys.len},
);
} else {
try out.writeAll(
\\You have already initialized envr.
\\Run again with the --force flag if you want to reinitialize.
\\
,
);
}
}
/// Returns true if the file exists
fn file_exists(io: std.Io, path: []const u8) bool {
if (std.Io.Dir.cwd().access(io, path, .{ .read = true })) {
return true;
} else |_| {
return false;
}
}
/// Returns a list of keys that the user has selected to add to their config.
/// Caller owns the returned memory
// TODO: Write a test for this
fn select_ssh_keys(
io: std.Io,
alloc: std.mem.Allocator,
home_path: []const u8,
out: *std.Io.Writer,
) ![]Config.SSHKeyPair {
const ssh_path = try std.fs.path.join(alloc, &.{ home_path, ".ssh" });
defer alloc.free(ssh_path);
// TODO: Arbitrary capacity chosen
var keys: std.ArrayList(Config.SSHKeyPair) = try .initCapacity(alloc, 3);
{
const ssh_dir = try std.Io.Dir.cwd().openDir(io, ssh_path, .{ .iterate = true });
defer ssh_dir.close(io);
var itr = ssh_dir.iterate();
const expect1 =
\\-----BEGIN OPENSSH PRIVATE KEY-----
\\
;
const expect2 =
\\-----BEGIN RSA PRIVATE KEY-----
\\
;
var buf: [expect1.len]u8 = undefined;
while (try itr.next(io)) |entry| {
switch (entry.kind) {
.file => {
var file = try ssh_dir.openFile(io, entry.name, .{});
_ = try file.readPositionalAll(io, &buf, 0);
// TODO: Faster to use hash or something?
if ( // zig fmt: off
std.mem.eql(u8, expect1, &buf) or
std.mem.eql(u8, expect2, buf[0..expect2.len])
) { // zig fmt: on
// File is a private ssh key
const full_path = try ssh_dir.realPathFileAlloc(
io,
entry.name,
alloc,
);
try keys.append(alloc, try .from_path(alloc, full_path));
}
},
.sym_link => {
// TODO: Handle symlinks
},
.block_device,
.character_device,
.directory,
.named_pipe,
.unix_domain_socket,
.whiteout,
.door,
.event_port,
.unknown,
=> continue,
}
}
}
for (keys.items, 1..) |key, n| {
try out.print("{d}. {s}\n", .{ n, key.private });
}
try out.writeAll(
"\nPlease enter the number(s) of SSH keys you'd like to use for encryption:\n> ",
);
try out.flush();
defer out.writeAll("\n\n") catch unreachable;
// TODO: ask user for number(s) to use.
// TODO: confirm with a y/n prompt
// TODO: only return selected keys
return keys.toOwnedSlice(alloc);
}
pub fn list(
io: Io,
arena: std.mem.Allocator,
out: *std.Io.Writer,
home: []const u8,
tmp: []const u8,
) !void {
// TODO: Don't hardcode
const cfgPath = try std.fs.path.join(arena, &.{ home, ".envr", "config.json" });
defer arena.free(cfgPath);
var cfg = (try Config.load(io, arena, cfgPath));
defer cfg.deinit();
var db: Db = try .open(io, arena, .{
.config = cfg.value,
.home = home,
.tmp = tmp,
});
const files = try db.list(arena);
defer arena.free(files);
const table: tabula.Table(Db.EnvFile, .initOne(.path)) = .{ .items = files };
try out.print("{f}", .{table});
try out.flush();
try db.close(io, arena); // TODO: Defer this
for (files) |*file| {
file.deinit(arena);
}
}
test {
std.testing.refAllDecls(@import("Config.zig"));
std.testing.refAllDecls(@import("Db.zig"));
}
test "enum type" {
const got: root.Type = @enumFromInt(3);
try std.testing.expectEqual(.version, got);
}
test "parse deps" {
const args = &[_][]const u8{"deps"};
const cmd = root.parse(args);
try std.testing.expectEqual(.deps, cmd);
}
test "parse list" {
const args = &[_][]const u8{"list"};
const cmd = root.parse(args);
try std.testing.expectEqual(.list, cmd);
}
test "parse version" {
const args = &[_][]const u8{"version"};
const cmd = root.parse(args);
try std.testing.expectEqual(.version, cmd);
}
test "parse unknown" {
const args = &[_][]const u8{ "bad", "value" };
const cmd = root.parse(args);
try std.testing.expectEqual(.unknown, cmd);
}
test "list returns a table" {
const io = std.testing.io;
const gpa = std.testing.allocator;
var tmp_dir = std.testing.tmpDir(.{});
defer tmp_dir.cleanup();
try tmp_dir.dir.createDir(io, "home", .default_dir);
try tmp_dir.dir.createDir(io, "home/.envr", .default_dir);
try tmp_dir.dir.createDir(io, "home/.ssh", .default_dir);
try tmp_dir.dir.createDir(io, "tmp", .default_dir);
const tmp_dir_path = try tmp_dir.dir.realPathFileAlloc(io, ".", gpa);
defer gpa.free(tmp_dir_path);
const home = try std.fs.path.join(gpa, &.{ tmp_dir_path, "home" });
defer gpa.free(home);
const tmp = try std.fs.path.join(gpa, &.{ tmp_dir_path, "tmp" });
defer gpa.free(tmp);
try std.Io.Dir.cwd().copyFile(
"fixtures/encrypted-single-file.db.age",
tmp_dir.dir,
"home/.envr/data.age",
io,
.{},
);
try std.Io.Dir.cwd().copyFile(
"fixtures/default_config.json",
tmp_dir.dir,
"home/.envr/config.json",
io,
.{},
);
try std.Io.Dir.cwd().copyFile(
"fixtures/insecure-test-key",
tmp_dir.dir,
"home/.ssh/id_ed25519",
io,
.{},
);
try std.Io.Dir.cwd().copyFile(
"fixtures/insecure-test-key.pub",
tmp_dir.dir,
"home/.ssh/id_ed25519.pub",
io,
.{},
);
var out: std.Io.Writer.Allocating = .init(gpa);
defer out.deinit();
// Run Test
try list(
io,
std.testing.allocator,
&out.writer,
home,
tmp,
);
const got = try out.toOwnedSlice();
defer gpa.free(got);
try std.testing.expectEqualStrings(
\\┌────────────────────────┐
\\│ path │
\\├────────────────────────┤
\\│ ~/project/.env.example │
\\└────────────────────────┘
\\
, got);
}

View File

@@ -1,311 +0,0 @@
const std = @import("std");
const hor = "";
const tl = "";
const tm = "";
const tr = "";
const sep = "";
const ml = "";
const mm = "";
const mr = "";
const bl = "";
const bm = "";
const br = "";
/// Prepare a TUI table to be written to a writer.
pub fn Table(
comptime T: type,
comptime fields: std.EnumSet(std.meta.FieldEnum(T)),
) type {
return struct {
items: []const T,
pub fn format(self: @This(), writer: *std.Io.Writer) !void {
const max_column_widths = determine_col_widths(T, self.items);
try header(T, fields, &max_column_widths, writer);
// Print body
for (self.items) |item| {
try writer.writeAll(sep);
comptime var itr = fields.iterator();
comptime var i: usize = 0;
inline while (comptime itr.next()) |c| : (i += 1) {
try writer.writeByte(' ');
try write_aligned(writer, @field(item, @tagName(c)), max_column_widths[i], .left);
try writer.print(" {s}", .{sep});
}
try writer.writeAll("\n");
}
// Print post-body
{
try writer.writeAll(bl);
var itr = fields.iterator();
var i: usize = 0;
while (itr.next()) |_| : (i += 1) {
if (i > 0) {
try writer.writeAll(bm);
}
const padding = max_column_widths[i] + 2;
for (0..padding) |_| {
try writer.writeAll(hor);
}
}
try writer.writeAll(br ++ "\n");
}
}
};
}
fn determine_col_widths(
T: type,
items: []const T,
) [@typeInfo(T).@"struct".fields.len]usize {
const all_fields = @typeInfo(T).@"struct".fields;
var max_column_widths: [all_fields.len]usize = @splat(0);
for (items) |item| {
inline for (all_fields, 0..) |field, i| {
// TODO: Get str len of item
const value_len = @field(item, field.name).len;
max_column_widths[i] = @max(
max_column_widths[i],
field.name.len,
value_len,
);
}
}
return max_column_widths;
}
// Print the header of a table
fn header(
T: type,
comptime fields: std.EnumSet(std.meta.FieldEnum(T)),
max_column_widths: []const usize,
writer: *std.Io.Writer,
) !void {
// Print Pre-Header
{
try writer.writeAll(tl);
inline for (0..comptime fields.count()) |i| {
if (i > 0) {
try writer.writeAll(tm);
}
const padding = max_column_widths[i] + 2;
for (0..padding) |_| {
try writer.writeAll(hor);
}
}
try writer.writeAll(tr ++ "\n");
}
// Main Header
{
try writer.writeAll(sep);
comptime var itr = fields.iterator();
comptime var i: usize = 0;
inline while (comptime itr.next()) |field| : (i += 1) {
try writer.writeByte(' ');
try write_aligned(
writer,
@tagName(field),
max_column_widths[i],
.center,
);
try writer.print(" {s}", .{sep});
}
try writer.writeByte('\n');
}
// Print post-header
{
try writer.writeAll(ml);
inline for (0..comptime fields.count()) |i| {
if (i > 0) {
try writer.writeAll(mm);
}
const padding = max_column_widths[i] + 2;
for (0..padding) |_| {
try writer.writeAll(hor);
}
}
try writer.writeAll(mr ++ "\n");
}
}
fn write_aligned(
writer: *std.Io.Writer,
data: []const u8,
max_width: usize,
alignment: Alignment,
) !void {
std.debug.assert(data.len > 0);
std.debug.assert(max_width >= data.len);
const padding: [2]usize = switch (alignment) {
.left => .{ 0, max_width - data.len },
.right => .{ max_width - data.len, 0 },
.center => blk: {
// Faster to inline the divFloor?
const half = @divFloor(max_width - data.len, 2);
break :blk .{ half, max_width - data.len - half };
},
};
for (0..padding[0]) |_| {
try writer.writeByte(' ');
}
try writer.writeAll(data);
for (0..padding[1]) |_| {
try writer.writeByte(' ');
}
}
const Alignment = enum { left, center, right };
test "can print a simple table" {
const gpa = std.testing.allocator;
var out: std.Io.Writer.Allocating = .init(gpa);
defer out.deinit();
const F = struct { foo: []const u8, bar: []const u8 };
const table: Table(F, .full) = .{
.items = &.{.{ .foo = "bat", .bar = "baz" }},
};
try out.writer.print("{f}", .{table});
const got = try out.toOwnedSlice();
defer gpa.free(got);
try std.testing.expectEqualStrings(
\\┌─────┬─────┐
\\│ foo │ bar │
\\├─────┼─────┤
\\│ bat │ baz │
\\└─────┴─────┘
\\
, got);
}
test "can print a table with varying header widths" {
const gpa = std.testing.allocator;
var out: std.Io.Writer.Allocating = .init(gpa);
defer out.deinit();
const F = struct { foo: []const u8, abart: []const u8 };
const table: Table(F, .full) = .{
.items = &.{.{ .foo = "bat", .abart = "baz" }},
};
try out.writer.print("{f}", .{table});
const got = try out.toOwnedSlice();
defer gpa.free(got);
try std.testing.expectEqualStrings(
\\┌─────┬───────┐
\\│ foo │ abart │
\\├─────┼───────┤
\\│ bat │ baz │
\\└─────┴───────┘
\\
, got);
}
test "can print a table with varying column widths" {
const gpa = std.testing.allocator;
var out: std.Io.Writer.Allocating = .init(gpa);
defer out.deinit();
const F = struct { foo: []const u8, bar: []const u8 };
const table: Table(F, .full) = .{ .items = &.{.{ .foo = "bat", .bar = "bazzar" }} };
try out.writer.print("{f}", .{table});
const got = try out.toOwnedSlice();
defer gpa.free(got);
try std.testing.expectEqualStrings(
\\┌─────┬────────┐
\\│ foo │ bar │
\\├─────┼────────┤
\\│ bat │ bazzar │
\\└─────┴────────┘
\\
, got);
}
test "can print a multi row table with varying column widths" {
const gpa = std.testing.allocator;
var out: std.Io.Writer.Allocating = .init(gpa);
defer out.deinit();
const F = struct { foo: []const u8, bar: []const u8 };
const table: Table(F, .full) = .{
.items = &.{
.{ .foo = "baz", .bar = "quz" },
.{ .foo = "bat", .bar = "bazzar" },
},
};
try out.writer.print("{f}", .{table});
const got = try out.toOwnedSlice();
defer gpa.free(got);
try std.testing.expectEqualStrings(
\\┌─────┬────────┐
\\│ foo │ bar │
\\├─────┼────────┤
\\│ baz │ quz │
\\│ bat │ bazzar │
\\└─────┴────────┘
\\
, got);
}
test "can print a table with limited columns" {
const gpa = std.testing.allocator;
var out: std.Io.Writer.Allocating = .init(gpa);
defer out.deinit();
const F = struct { foo: []const u8, bar: []const u8 };
const table: Table(F, .initOne(.foo)) = .{
.items = &.{.{ .foo = "bat", .bar = "baz" }},
};
try out.writer.print("{f}", .{table});
const got = try out.toOwnedSlice();
defer gpa.free(got);
try std.testing.expectEqualStrings(
\\┌─────┐
\\│ foo │
\\├─────┤
\\│ bat │
\\└─────┘
\\
, got);
}

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,
}
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
}
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"
}

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)",
)
}

View File

@@ -3,26 +3,18 @@ package main
import "core:encoding/json" import "core:encoding/json"
import "core:fmt" import "core:fmt"
import "core:io" import "core:io"
import "core:os"
import "core:strings" import "core:strings"
render_table :: proc(headers: []string, rows: [][]string) { render_table :: proc(w: io.Writer, headers: []string, rows: [][]string) {
if !is_tty() {
w := io.to_writer(os.to_writer(os.stdout))
render_json_rows(w, headers, rows)
io.write_string(w, "\n")
return
}
col_widths := make([dynamic]int, 0, len(headers)) col_widths := make([dynamic]int, 0, len(headers))
for i in 0 ..< len(headers) { for i in 0 ..< len(headers) {
append(&col_widths, strings.rune_count(headers[i])) append(&col_widths, strings.rune_count(headers[i]))
} }
for r in rows { for r in rows {
for i in 0 ..< len(r) { for i in 0 ..< len(r) {
w := strings.rune_count(r[i]) rw := strings.rune_count(r[i])
if i < len(col_widths) && w > col_widths[i] { if i < len(col_widths) && rw > col_widths[i] {
col_widths[i] = w col_widths[i] = rw
} }
} }
} }
@@ -32,7 +24,7 @@ render_table :: proc(headers: []string, rows: [][]string) {
defer strings.builder_destroy(&b) defer strings.builder_destroy(&b)
defer delete(col_widths) defer delete(col_widths)
hline :: proc(b: ^strings.Builder, left, mid, right: string, widths: [dynamic]int) { hline :: proc(w: io.Writer, b: ^strings.Builder, left, mid, right: string, widths: [dynamic]int) {
strings.write_string(b, left) strings.write_string(b, left)
for i in 0 ..< len(widths) { for i in 0 ..< len(widths) {
for _ in 0 ..< widths[i] + 2 { for _ in 0 ..< widths[i] + 2 {
@@ -44,11 +36,11 @@ render_table :: proc(headers: []string, rows: [][]string) {
strings.write_string(b, right) strings.write_string(b, right)
} }
} }
fmt.println(strings.to_string(b^)) fmt.wprintf(w, "%s\n", strings.to_string(b^), flush = false)
strings.builder_reset(b) strings.builder_reset(b)
} }
hline(&b, "\u250c", "\u252c", "\u2510", col_widths) hline(w, &b, "\u250c", "\u252c", "\u2510", col_widths)
cell :: proc(b: ^strings.Builder, s: string, width: int) { cell :: proc(b: ^strings.Builder, s: string, width: int) {
extra := len(s) - strings.rune_count(s) extra := len(s) - strings.rune_count(s)
@@ -59,21 +51,21 @@ render_table :: proc(headers: []string, rows: [][]string) {
for i in 0 ..< len(headers) { for i in 0 ..< len(headers) {
cell(&b, headers[i], col_widths[i]) cell(&b, headers[i], col_widths[i])
} }
fmt.println(strings.to_string(b)) fmt.wprintf(w, "%s\n", strings.to_string(b), flush = false)
strings.builder_reset(&b) strings.builder_reset(&b)
hline(&b, "\u251c", "\u253c", "\u2524", col_widths) hline(w, &b, "\u251c", "\u253c", "\u2524", col_widths)
for r in rows { for r in rows {
strings.write_string(&b, "\u2502") strings.write_string(&b, "\u2502")
for i in 0 ..< len(r) { for i in 0 ..< len(r) {
cell(&b, r[i], col_widths[i]) cell(&b, r[i], col_widths[i])
} }
fmt.println(strings.to_string(b)) fmt.wprintf(w, "%s\n", strings.to_string(b), flush = false)
strings.builder_reset(&b) strings.builder_reset(&b)
} }
hline(&b, "\u2514", "\u2534", "\u2518", col_widths) hline(w, &b, "\u2514", "\u2534", "\u2518", col_widths)
} }
render_json_rows :: proc(w: io.Writer, headers: []string, rows: [][]string) { render_json_rows :: proc(w: io.Writer, headers: []string, rows: [][]string) {

View File

@@ -2,7 +2,6 @@ package main
import "core:encoding/json" import "core:encoding/json"
import "core:fmt" import "core:fmt"
import "core:io"
import "core:strings" import "core:strings"
import "core:testing" import "core:testing"
@@ -103,3 +102,60 @@ test_render_json_rows_empty :: proc(t: ^testing.T) {
testing.expect(t, len(result) == 0) 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)
testing.expect(t, strings.contains(output, "Name"), "header 'Name' missing from output")
testing.expect(t, strings.contains(output, "Path"), "header 'Path' missing from output")
testing.expect(t, strings.contains(output, "foo"), "cell 'foo' missing from output")
testing.expect(t, strings.contains(output, "/home/user/.env"), "cell '/home/user/.env' missing from output")
testing.expect(t, strings.contains(output, "bar"), "cell 'bar' missing from output")
testing.expect(t, strings.contains(output, "/home/user/project/.env"), "cell '/home/user/project/.env' missing")
}
@(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)
testing.expect(t, strings.contains(output, "Name"), "header 'Name' missing from 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)
testing.expect(t, strings.contains(output, "Available"), "unicode cell content missing")
testing.expect(t, strings.contains(output, "Missing"), "unicode cell content missing")
}

View File

@@ -1,8 +0,0 @@
package main
import "core:sys/posix"
is_tty :: proc() -> bool {
return bool(posix.isatty(1))
}

View File

@@ -1,13 +0,0 @@
package main
import "core:fmt"
VERSION :: "0.2.0"
cmd_version :: proc(cmd: ^Command) {
if has_flag(cmd, "long") || has_flag(cmd, "l") {
fmt.printf("envr version %s\n", VERSION)
} else {
fmt.println(VERSION)
}
}

1
version.txt Normal file
View File

@@ -0,0 +1 @@
0.2.0

View File

@@ -1 +0,0 @@
target

File diff suppressed because it is too large Load Diff

View File

@@ -1,15 +0,0 @@
[package]
name = "age-ffi"
version = "0.1.0"
edition = "2021"
[lib]
crate-type = ["staticlib"]
[dependencies]
age = { version = "0.11", features = ["armor", "ssh", "plugin", "cli-common"] }
secrecy = "0.10"
libc = "0.2"
[profile.release]
lto = true

View File

@@ -1,165 +0,0 @@
# age-ffi
A Rust FFI wrapper for the [age](https://github.com/str4d/rage) encryption library, with Zig bindings.
## Overview
This library provides C-compatible FFI bindings for the age encryption library, making it easy to use age encryption from other languages. It includes comprehensive Zig bindings and examples.
## Features
- **X25519 encryption** - Standard age public key encryption (`age1...`)
- **SSH key support** - Encrypt to SSH keys (`ssh-ed25519`, `ssh-rsa`)
- **Plugin support** - Full support for age plugins including:
- [age-plugin-se](https://github.com/remko/age-plugin-se) (Secure Enclave on macOS)
- [age-plugin-yubikey](https://github.com/str4d/age-plugin-yubikey)
- Any other age-compatible plugin
- **Passphrase encryption** - Scrypt-based passphrase encryption
- **Multiple recipients** - Encrypt to multiple recipients at once
- **Armor format** - ASCII-armored output support
- **File operations** - Direct file encryption/decryption
- **Memory-safe API** - Proper error handling and memory management
- **Comprehensive test suite**
## Supported Identity/Recipient Types
| Type | Recipient Format | Identity Format |
|------|-----------------|-----------------|
| X25519 | `age1...` | `AGE-SECRET-KEY-1...` |
| SSH | `ssh-ed25519 ...`, `ssh-rsa ...` | SSH private key file |
| Plugin | `age1<plugin>1...` | `AGE-PLUGIN-<NAME>-1...` |
| Passphrase | N/A | Passphrase string |
## Building
### Rust Library
```bash
cargo build --release
```
This produces `target/release/libage_ffi.a` (static library).
### Zig Bindings
```bash
cd zig
zig build
```
Run the example:
```bash
cd zig
zig build run
```
Run tests:
```bash
cd zig
zig build test
```
## Usage
### Zig
```zig
const age = @import("age");
// Generate a keypair
var keypair = try age.generateKeypair();
defer keypair.deinit();
// Encrypt data
const plaintext = "Hello, World!";
var encrypted = try age.encrypt(plaintext, keypair.getPublicKey());
defer encrypted.deinit();
// Decrypt data
var decrypted = try age.decrypt(encrypted.toSlice(), keypair.getPrivateKey());
defer decrypted.deinit();
// File operations with plugin support
try age.encryptToFile(plaintext, "age1se1...", "/path/to/output.age");
var content = try age.decryptFile("/path/to/file.age", "/path/to/identities");
defer content.deinit();
```
### C
```c
#include <age_ffi.h>
// Generate keypair
AgeKeypair keypair;
age_generate_keypair(&keypair);
// Encrypt
AgeBuffer encrypted;
age_encrypt(plaintext, plaintext_len, keypair.public_key, &encrypted);
// Decrypt
AgeBuffer decrypted;
age_decrypt(encrypted.data, encrypted.len, keypair.private_key, &decrypted);
// Free resources
age_free_buffer(&encrypted);
age_free_buffer(&decrypted);
age_free_keypair(&keypair);
```
## Plugin Support
This library supports the [age plugin protocol](https://github.com/C2SP/C2SP/blob/main/age.md), allowing encryption and decryption with hardware-backed keys and other plugin-based identities.
### Requirements
- The plugin binary must be in your `$PATH` (e.g., `age-plugin-se`)
- For Secure Enclave: macOS with Touch ID or Apple Watch
### Example with Secure Enclave
```bash
# Install the plugin
brew install age-plugin-se
# Generate a Secure Enclave identity
age-plugin-se --generate -o ~/.age/se-identity.txt
# The library will automatically use the plugin when it sees:
# - Recipients starting with age1se1...
# - Identities starting with AGE-PLUGIN-SE-...
```
## API Reference
### Key Generation
- `age_generate_keypair()` - Generate X25519 keypair
- `age_generate_x25519()` - Generate X25519 keypair (alias)
- `age_x25519_to_public()` - Derive public key from private key
### Encryption
- `age_encrypt()` - Encrypt to a single recipient
- `age_encrypt_multi()` - Encrypt to multiple recipients
- `age_encrypt_armor()` - Encrypt with ASCII armor
- `age_encrypt_passphrase()` - Encrypt with passphrase
- `age_encrypt_to_file()` - Encrypt directly to file
### Decryption
- `age_decrypt()` - Decrypt with identity string
- `age_decrypt_multi()` - Decrypt with multiple identities
- `age_decrypt_file()` - Decrypt file using identity file (supports plugins)
- `age_decrypt_passphrase()` - Decrypt with passphrase
### Utilities
- `age_armor()` - Wrap binary data in ASCII armor
- `age_dearmor()` - Unwrap ASCII-armored data
- `age_validate_recipient()` - Check if recipient string is valid
- `age_validate_identity()` - Check if identity string is valid
- `age_version()` - Get library version
## License
This project is dual-licensed under MIT and Apache-2.0, matching the age library.

View File

@@ -1,95 +0,0 @@
//! ASCII armor utilities.
use crate::helpers::cstr_to_str;
use crate::helpers::string_to_cstr;
use crate::types::{AgeBuffer, AgeResult};
use std::io::{Read, Write};
use std::os::raw::c_char;
/// Wrap binary data in ASCII armor.
///
/// # Arguments
/// * `data` - Pointer to the binary data
/// * `data_len` - Length of the data
/// * `output` - Pointer to receive the armored string
///
/// # Returns
/// AgeResult indicating success or failure
#[no_mangle]
pub extern "C" fn age_armor(
data: *const u8,
data_len: usize,
output: *mut *mut c_char,
) -> AgeResult {
if data.is_null() || output.is_null() {
return AgeResult::InvalidInput;
}
let data = unsafe { std::slice::from_raw_parts(data, data_len) };
let mut armored = Vec::new();
let mut writer = match age::armor::ArmoredWriter::wrap_output(&mut armored, age::armor::Format::AsciiArmor) {
Ok(w) => w,
Err(_) => return AgeResult::ArmorError,
};
if writer.write_all(data).is_err() {
return AgeResult::ArmorError;
}
if writer.finish().is_err() {
return AgeResult::ArmorError;
}
let armored_str = match String::from_utf8(armored) {
Ok(s) => s,
Err(_) => return AgeResult::ArmorError,
};
let c_output = match string_to_cstr(armored_str) {
Ok(s) => s,
Err(e) => return e,
};
unsafe {
*output = c_output;
}
AgeResult::Success
}
/// Remove ASCII armor from data.
///
/// # Arguments
/// * `armored` - The armored string
/// * `output` - Pointer to receive the binary buffer
///
/// # Returns
/// AgeResult indicating success or failure
#[no_mangle]
pub extern "C" fn age_dearmor(
armored: *const c_char,
output: *mut AgeBuffer,
) -> AgeResult {
if output.is_null() {
return AgeResult::InvalidInput;
}
let armored_str = match unsafe { cstr_to_str(armored) } {
Ok(s) => s,
Err(e) => return e,
};
let mut reader = age::armor::ArmoredReader::new(armored_str.as_bytes());
let mut dearmored = Vec::new();
if reader.read_to_end(&mut dearmored).is_err() {
return AgeResult::ArmorError;
}
unsafe {
*output = AgeBuffer::from_vec(dearmored);
}
AgeResult::Success
}

View File

@@ -1,175 +0,0 @@
//! Tests for ASCII armor utilities.
use crate::armor::*;
use crate::encrypt::*;
use crate::keys::*;
use crate::memory::*;
use crate::types::*;
use std::ffi::{CStr, CString};
use std::os::raw::c_char;
#[test]
fn test_armor_basic() {
let data = b"Hello, this is binary data to armor!";
let mut armored: *mut c_char = std::ptr::null_mut();
let result = age_armor(data.as_ptr(), data.len(), &mut armored);
assert_eq!(result, AgeResult::Success);
assert!(!armored.is_null());
let armored_str = unsafe { CStr::from_ptr(armored).to_str().unwrap() };
assert!(armored_str.starts_with("-----BEGIN AGE ENCRYPTED FILE-----"));
assert!(armored_str.contains("-----END AGE ENCRYPTED FILE-----"));
age_free_string(armored);
}
#[test]
fn test_dearmor_basic() {
let data = b"Test data for dearmoring";
let mut armored: *mut c_char = std::ptr::null_mut();
age_armor(data.as_ptr(), data.len(), &mut armored);
let mut dearmored = AgeBuffer::null();
let result = age_dearmor(armored, &mut dearmored);
assert_eq!(result, AgeResult::Success);
let dearmored_slice = unsafe { std::slice::from_raw_parts(dearmored.data, dearmored.len) };
assert_eq!(dearmored_slice, data);
age_free_string(armored);
age_free_buffer(&mut dearmored);
}
#[test]
fn test_armor_round_trip() {
// Test with various data sizes (skip empty - armor requires data)
let test_data = [
b"A".to_vec(),
b"Short".to_vec(),
(0u16..256).map(|i| i as u8).collect::<Vec<u8>>(),
vec![0u8; 1000],
(0..10000).map(|i| (i % 256) as u8).collect::<Vec<u8>>(),
];
for data in &test_data {
let mut armored: *mut c_char = std::ptr::null_mut();
let result = age_armor(data.as_ptr(), data.len(), &mut armored);
assert_eq!(result, AgeResult::Success, "Failed to armor data of len {}", data.len());
let mut dearmored = AgeBuffer::null();
let result = age_dearmor(armored, &mut dearmored);
assert_eq!(result, AgeResult::Success, "Failed to dearmor data of len {}", data.len());
let dearmored_slice = unsafe { std::slice::from_raw_parts(dearmored.data, dearmored.len) };
assert_eq!(dearmored_slice, data.as_slice());
age_free_string(armored);
age_free_buffer(&mut dearmored);
}
}
#[test]
fn test_armor_null_input() {
let mut armored: *mut c_char = std::ptr::null_mut();
let result = age_armor(std::ptr::null(), 0, &mut armored);
assert_eq!(result, AgeResult::InvalidInput);
let result = age_armor(b"test".as_ptr(), 4, std::ptr::null_mut());
assert_eq!(result, AgeResult::InvalidInput);
}
#[test]
fn test_dearmor_null_input() {
let mut dearmored = AgeBuffer::null();
let result = age_dearmor(std::ptr::null(), &mut dearmored);
assert_eq!(result, AgeResult::InvalidInput);
}
#[test]
fn test_dearmor_null_output() {
let data = b"test";
let mut armored: *mut c_char = std::ptr::null_mut();
age_armor(data.as_ptr(), data.len(), &mut armored);
let result = age_dearmor(armored, std::ptr::null_mut());
assert_eq!(result, AgeResult::InvalidInput);
age_free_string(armored);
}
#[test]
fn test_dearmor_invalid_armor() {
let invalid_armor = CString::new("This is not valid armor").unwrap();
let mut dearmored = AgeBuffer::null();
let result = age_dearmor(invalid_armor.as_ptr(), &mut dearmored);
// Should still succeed but return the data as-is or fail gracefully
// The ArmoredReader is forgiving and may just return the raw data
// Let's check that it doesn't crash at least
if result == AgeResult::Success {
age_free_buffer(&mut dearmored);
}
}
#[test]
fn test_encrypt_armor_and_dearmor() {
let mut keypair = AgeKeypair::null();
age_generate_x25519(&mut keypair);
let plaintext = b"Test encrypt -> armor -> dearmor -> decrypt";
let mut armored: *mut c_char = std::ptr::null_mut();
let result = age_encrypt_armor(
plaintext.as_ptr(),
plaintext.len(),
keypair.public_key,
&mut armored,
);
assert_eq!(result, AgeResult::Success);
// Dearmor
let mut dearmored = AgeBuffer::null();
let result = age_dearmor(armored, &mut dearmored);
assert_eq!(result, AgeResult::Success);
// Decrypt
let mut decrypted = AgeBuffer::null();
let result = crate::decrypt::age_decrypt(
dearmored.data,
dearmored.len,
keypair.private_key,
&mut decrypted,
);
assert_eq!(result, AgeResult::Success);
let decrypted_slice = unsafe { std::slice::from_raw_parts(decrypted.data, decrypted.len) };
assert_eq!(decrypted_slice, plaintext);
age_free_string(armored);
age_free_buffer(&mut dearmored);
age_free_buffer(&mut decrypted);
age_free_keypair(&mut keypair);
}
#[test]
fn test_armor_binary_data() {
// Test with binary data including null bytes
let binary_data: Vec<u8> = (0u16..256).map(|i| i as u8).collect();
let mut armored: *mut c_char = std::ptr::null_mut();
let result = age_armor(binary_data.as_ptr(), binary_data.len(), &mut armored);
assert_eq!(result, AgeResult::Success);
let mut dearmored = AgeBuffer::null();
let result = age_dearmor(armored, &mut dearmored);
assert_eq!(result, AgeResult::Success);
let dearmored_slice = unsafe { std::slice::from_raw_parts(dearmored.data, dearmored.len) };
assert_eq!(dearmored_slice, binary_data.as_slice());
age_free_string(armored);
age_free_buffer(&mut dearmored);
}

View File

@@ -1,299 +0,0 @@
//! In-memory decryption functions.
use crate::helpers::cstr_to_str;
use crate::types::{AgeBuffer, AgeResult};
use age::ssh;
use std::io::{BufReader, Read};
use std::os::raw::c_char;
use std::str::FromStr;
/// Decrypt data in memory using a single x25519 identity.
/// This is a simple API for common use cases.
///
/// # Arguments
/// * `ciphertext` - Pointer to the encrypted data
/// * `ciphertext_len` - Length of the ciphertext
/// * `identity` - The private key string (AGE-SECRET-KEY-1...)
/// * `output` - Pointer to receive the decrypted buffer
///
/// # Returns
/// AgeResult indicating success or failure
#[no_mangle]
pub extern "C" fn age_decrypt(
ciphertext: *const u8,
ciphertext_len: usize,
identity: *const c_char,
output: *mut AgeBuffer,
) -> AgeResult {
if ciphertext.is_null() || output.is_null() {
return AgeResult::InvalidInput;
}
let ciphertext = unsafe { std::slice::from_raw_parts(ciphertext, ciphertext_len) };
let identity_str = match unsafe { cstr_to_str(identity) } {
Ok(s) => s,
Err(e) => return e,
};
let identity = match age::x25519::Identity::from_str(identity_str) {
Ok(i) => i,
Err(_) => return AgeResult::InvalidIdentity,
};
let decrypted = match age::decrypt(&identity, ciphertext) {
Ok(d) => d,
Err(_) => return AgeResult::DecryptionFailed,
};
unsafe {
*output = AgeBuffer::from_vec(decrypted);
}
AgeResult::Success
}
/// Decrypt data in memory using multiple identities.
/// The library will try each identity until one succeeds.
///
/// # Arguments
/// * `ciphertext` - Pointer to the encrypted data
/// * `ciphertext_len` - Length of the ciphertext
/// * `identities` - Array of identity C strings
/// * `identity_count` - Number of identities
/// * `output` - Pointer to receive the decrypted buffer
///
/// # Returns
/// AgeResult indicating success or failure
#[no_mangle]
pub extern "C" fn age_decrypt_multi(
ciphertext: *const u8,
ciphertext_len: usize,
identities: *const *const c_char,
identity_count: usize,
output: *mut AgeBuffer,
) -> AgeResult {
if ciphertext.is_null() || identities.is_null() || output.is_null() || identity_count == 0 {
return AgeResult::InvalidInput;
}
let ciphertext = unsafe { std::slice::from_raw_parts(ciphertext, ciphertext_len) };
let identity_ptrs = unsafe { std::slice::from_raw_parts(identities, identity_count) };
let mut parsed_identities: Vec<Box<dyn age::Identity>> = Vec::new();
for &ptr in identity_ptrs {
let identity_str = match unsafe { cstr_to_str(ptr) } {
Ok(s) => s.trim(),
Err(e) => return e,
};
// Try x25519 first
if let Ok(i) = age::x25519::Identity::from_str(identity_str) {
parsed_identities.push(Box::new(i));
continue;
}
// Skip comments and empty lines
if identity_str.is_empty() || identity_str.starts_with('#') {
continue;
}
return AgeResult::InvalidIdentity;
}
if parsed_identities.is_empty() {
return AgeResult::NoIdentities;
}
let decryptor = match age::Decryptor::new(ciphertext) {
Ok(d) => d,
Err(_) => return AgeResult::DecryptionFailed,
};
let mut decrypted = Vec::new();
let mut reader = match decryptor.decrypt(parsed_identities.iter().map(|i| i.as_ref())) {
Ok(r) => r,
Err(_) => return AgeResult::DecryptionFailed,
};
if reader.read_to_end(&mut decrypted).is_err() {
return AgeResult::DecryptionFailed;
}
unsafe {
*output = AgeBuffer::from_vec(decrypted);
}
AgeResult::Success
}
/// Decrypt data using an SSH private key.
/// Supports both Ed25519 and RSA SSH keys.
///
/// # Arguments
/// * `ciphertext` - Pointer to the encrypted data
/// * `ciphertext_len` - Length of the ciphertext
/// * `ssh_key` - The SSH private key in PEM or OpenSSH format
/// * `passphrase` - Optional passphrase for encrypted SSH keys (can be null)
/// * `output` - Pointer to receive the decrypted buffer
///
/// # Returns
/// AgeResult indicating success or failure
#[no_mangle]
pub extern "C" fn age_decrypt_ssh(
ciphertext: *const u8,
ciphertext_len: usize,
ssh_key: *const c_char,
passphrase: *const c_char,
output: *mut AgeBuffer,
) -> AgeResult {
if ciphertext.is_null() || output.is_null() {
return AgeResult::InvalidInput;
}
let ciphertext = unsafe { std::slice::from_raw_parts(ciphertext, ciphertext_len) };
let ssh_key_str = match unsafe { cstr_to_str(ssh_key) } {
Ok(s) => s,
Err(e) => return e,
};
// Parse SSH identity from buffer
let buf_reader = BufReader::new(ssh_key_str.as_bytes());
let identity = match ssh::Identity::from_buffer(buf_reader, None) {
Ok(id) => id,
Err(_) => return AgeResult::SshKeyError,
};
// Handle encrypted SSH keys - keep as ssh::Identity since it implements age::Identity
let identity: ssh::Identity = match identity {
ssh::Identity::Unencrypted(_) => identity,
ssh::Identity::Encrypted(enc) => {
let passphrase_str = if passphrase.is_null() {
return AgeResult::PassphraseRequired;
} else {
match unsafe { cstr_to_str(passphrase) } {
Ok(s) if !s.is_empty() => s,
_ => return AgeResult::PassphraseRequired,
}
};
match enc.decrypt(age::secrecy::SecretString::from(passphrase_str.to_string())) {
Ok(id) => ssh::Identity::Unencrypted(id),
Err(_) => return AgeResult::InvalidPassphrase,
}
}
ssh::Identity::Unsupported(_) => return AgeResult::UnsupportedKey,
};
let decryptor = match age::Decryptor::new(ciphertext) {
Ok(d) => d,
Err(_) => return AgeResult::DecryptionFailed,
};
let mut decrypted = Vec::new();
let mut reader = match decryptor.decrypt(std::iter::once(&identity as &dyn age::Identity)) {
Ok(r) => r,
Err(_) => return AgeResult::DecryptionFailed,
};
if reader.read_to_end(&mut decrypted).is_err() {
return AgeResult::DecryptionFailed;
}
unsafe {
*output = AgeBuffer::from_vec(decrypted);
}
AgeResult::Success
}
/// Decrypt data using an SSH private key file.
///
/// # Arguments
/// * `ciphertext` - Pointer to the encrypted data
/// * `ciphertext_len` - Length of the ciphertext
/// * `ssh_key_path` - Path to the SSH private key file
/// * `passphrase` - Optional passphrase for encrypted SSH keys (can be null)
/// * `output` - Pointer to receive the decrypted buffer
///
/// # Returns
/// AgeResult indicating success or failure
#[no_mangle]
pub extern "C" fn age_decrypt_ssh_file(
ciphertext: *const u8,
ciphertext_len: usize,
ssh_key_path: *const c_char,
passphrase: *const c_char,
output: *mut AgeBuffer,
) -> AgeResult {
if ciphertext.is_null() || output.is_null() {
return AgeResult::InvalidInput;
}
let ciphertext = unsafe { std::slice::from_raw_parts(ciphertext, ciphertext_len) };
let path_str = match unsafe { cstr_to_str(ssh_key_path) } {
Ok(s) => s,
Err(e) => return e,
};
// The filename is passed as a hint for error messages
let filename = Some(path_str.to_string());
// Read and parse SSH key file
let ssh_key_data = match std::fs::read(path_str) {
Ok(data) => data,
Err(_) => return AgeResult::IoError,
};
let buf_reader = BufReader::new(ssh_key_data.as_slice());
let identity = match ssh::Identity::from_buffer(buf_reader, filename) {
Ok(id) => id,
Err(_) => return AgeResult::SshKeyError,
};
// Handle encrypted SSH keys - keep as ssh::Identity since it implements age::Identity
let identity: ssh::Identity = match identity {
ssh::Identity::Unencrypted(_) => identity,
ssh::Identity::Encrypted(enc) => {
// Parse passphrase if provided
let passphrase_str = if passphrase.is_null() {
return AgeResult::PassphraseRequired;
} else {
match unsafe { cstr_to_str(passphrase) } {
Ok(s) if !s.is_empty() => s,
_ => return AgeResult::PassphraseRequired,
}
};
match enc.decrypt(age::secrecy::SecretString::from(passphrase_str.to_string())) {
Ok(id) => ssh::Identity::Unencrypted(id),
Err(_) => return AgeResult::InvalidPassphrase,
}
}
ssh::Identity::Unsupported(_) => return AgeResult::UnsupportedKey,
};
let decryptor = match age::Decryptor::new(ciphertext) {
Ok(d) => d,
Err(_) => return AgeResult::DecryptionFailed,
};
let mut decrypted = Vec::new();
let mut reader = match decryptor.decrypt(std::iter::once(&identity as &dyn age::Identity)) {
Ok(r) => r,
Err(_) => return AgeResult::DecryptionFailed,
};
if reader.read_to_end(&mut decrypted).is_err() {
return AgeResult::DecryptionFailed;
}
unsafe {
*output = AgeBuffer::from_vec(decrypted);
}
AgeResult::Success
}

View File

@@ -1,430 +0,0 @@
//! Tests for in-memory decryption functions.
use crate::decrypt::*;
use crate::encrypt::*;
use crate::keys::*;
use crate::memory::*;
use crate::types::*;
use std::ffi::CString;
use std::os::raw::c_char;
#[test]
fn test_basic_decrypt() {
let mut keypair = AgeKeypair::null();
age_generate_x25519(&mut keypair);
let plaintext = b"Decryption test message";
let mut encrypted = AgeBuffer::null();
age_encrypt(plaintext.as_ptr(), plaintext.len(), keypair.public_key, &mut encrypted);
let mut decrypted = AgeBuffer::null();
let result = age_decrypt(
encrypted.data,
encrypted.len,
keypair.private_key,
&mut decrypted,
);
assert_eq!(result, AgeResult::Success);
let decrypted_slice = unsafe { std::slice::from_raw_parts(decrypted.data, decrypted.len) };
assert_eq!(decrypted_slice, plaintext);
age_free_buffer(&mut encrypted);
age_free_buffer(&mut decrypted);
age_free_keypair(&mut keypair);
}
#[test]
fn test_decrypt_null_ciphertext() {
let mut keypair = AgeKeypair::null();
age_generate_x25519(&mut keypair);
let mut decrypted = AgeBuffer::null();
let result = age_decrypt(
std::ptr::null(),
0,
keypair.private_key,
&mut decrypted,
);
assert_eq!(result, AgeResult::InvalidInput);
age_free_keypair(&mut keypair);
}
#[test]
fn test_decrypt_null_output() {
let mut keypair = AgeKeypair::null();
age_generate_x25519(&mut keypair);
let plaintext = b"test";
let mut encrypted = AgeBuffer::null();
age_encrypt(plaintext.as_ptr(), plaintext.len(), keypair.public_key, &mut encrypted);
let result = age_decrypt(
encrypted.data,
encrypted.len,
keypair.private_key,
std::ptr::null_mut(),
);
assert_eq!(result, AgeResult::InvalidInput);
age_free_buffer(&mut encrypted);
age_free_keypair(&mut keypair);
}
#[test]
fn test_decrypt_invalid_identity() {
let mut keypair = AgeKeypair::null();
age_generate_x25519(&mut keypair);
let plaintext = b"test";
let mut encrypted = AgeBuffer::null();
age_encrypt(plaintext.as_ptr(), plaintext.len(), keypair.public_key, &mut encrypted);
let invalid_identity = CString::new("not-a-valid-identity").unwrap();
let mut decrypted = AgeBuffer::null();
let result = age_decrypt(
encrypted.data,
encrypted.len,
invalid_identity.as_ptr(),
&mut decrypted,
);
assert_eq!(result, AgeResult::InvalidIdentity);
age_free_buffer(&mut encrypted);
age_free_keypair(&mut keypair);
}
#[test]
fn test_decrypt_wrong_key() {
let mut keypair1 = AgeKeypair::null();
let mut keypair2 = AgeKeypair::null();
age_generate_x25519(&mut keypair1);
age_generate_x25519(&mut keypair2);
let plaintext = b"Secret message";
let mut encrypted = AgeBuffer::null();
age_encrypt(plaintext.as_ptr(), plaintext.len(), keypair1.public_key, &mut encrypted);
// Try to decrypt with wrong key
let mut decrypted = AgeBuffer::null();
let result = age_decrypt(
encrypted.data,
encrypted.len,
keypair2.private_key, // Wrong key!
&mut decrypted,
);
assert_eq!(result, AgeResult::DecryptionFailed);
age_free_buffer(&mut encrypted);
age_free_keypair(&mut keypair1);
age_free_keypair(&mut keypair2);
}
#[test]
fn test_decrypt_corrupted_ciphertext() {
let mut keypair = AgeKeypair::null();
age_generate_x25519(&mut keypair);
let plaintext = b"Original message";
let mut encrypted = AgeBuffer::null();
age_encrypt(plaintext.as_ptr(), plaintext.len(), keypair.public_key, &mut encrypted);
// Corrupt the ciphertext
if encrypted.len > 50 {
unsafe {
*encrypted.data.add(50) ^= 0xFF;
}
}
let mut decrypted = AgeBuffer::null();
let result = age_decrypt(
encrypted.data,
encrypted.len,
keypair.private_key,
&mut decrypted,
);
// Should fail (either DecryptionFailed or other error depending on what was corrupted)
assert_ne!(result, AgeResult::Success);
age_free_buffer(&mut encrypted);
age_free_keypair(&mut keypair);
}
#[test]
fn test_decrypt_multi_with_multiple_identities() {
let mut keypair1 = AgeKeypair::null();
let mut keypair2 = AgeKeypair::null();
age_generate_x25519(&mut keypair1);
age_generate_x25519(&mut keypair2);
let plaintext = b"Multi-identity message";
let recipients: [*const c_char; 1] = [keypair1.public_key as *const c_char];
let mut encrypted = AgeBuffer::null();
age_encrypt_multi(
plaintext.as_ptr(),
plaintext.len(),
recipients.as_ptr(),
1,
false,
&mut encrypted,
);
// Decrypt with multiple identities (one valid, one invalid for this message)
let identities: [*const c_char; 2] = [
keypair2.private_key as *const c_char, // Wrong key first
keypair1.private_key as *const c_char, // Correct key
];
let mut decrypted = AgeBuffer::null();
let result = age_decrypt_multi(
encrypted.data,
encrypted.len,
identities.as_ptr(),
2,
&mut decrypted,
);
assert_eq!(result, AgeResult::Success);
let decrypted_slice = unsafe { std::slice::from_raw_parts(decrypted.data, decrypted.len) };
assert_eq!(decrypted_slice, plaintext);
age_free_buffer(&mut encrypted);
age_free_buffer(&mut decrypted);
age_free_keypair(&mut keypair1);
age_free_keypair(&mut keypair2);
}
#[test]
fn test_decrypt_multi_empty_identities() {
let plaintext = b"test";
let mut keypair = AgeKeypair::null();
age_generate_x25519(&mut keypair);
let mut encrypted = AgeBuffer::null();
age_encrypt(plaintext.as_ptr(), plaintext.len(), keypair.public_key, &mut encrypted);
let mut decrypted = AgeBuffer::null();
let result = age_decrypt_multi(
encrypted.data,
encrypted.len,
std::ptr::null(),
0,
&mut decrypted,
);
assert_eq!(result, AgeResult::InvalidInput);
age_free_buffer(&mut encrypted);
age_free_keypair(&mut keypair);
}
#[test]
fn test_decrypt_null_identity() {
let mut keypair = AgeKeypair::null();
age_generate_x25519(&mut keypair);
let plaintext = b"test";
let mut encrypted = AgeBuffer::null();
age_encrypt(plaintext.as_ptr(), plaintext.len(), keypair.public_key, &mut encrypted);
let mut decrypted = AgeBuffer::null();
let result = age_decrypt(
encrypted.data,
encrypted.len,
std::ptr::null(),
&mut decrypted,
);
assert_eq!(result, AgeResult::InvalidInput);
age_free_buffer(&mut encrypted);
age_free_keypair(&mut keypair);
}
#[test]
fn test_decrypt_multi_null_identity_in_array() {
let mut keypair = AgeKeypair::null();
age_generate_x25519(&mut keypair);
let plaintext = b"test";
let mut encrypted = AgeBuffer::null();
age_encrypt(plaintext.as_ptr(), plaintext.len(), keypair.public_key, &mut encrypted);
// Array with a null pointer inside
let identities: [*const c_char; 2] = [
keypair.private_key as *const c_char,
std::ptr::null(),
];
let mut decrypted = AgeBuffer::null();
let result = age_decrypt_multi(
encrypted.data,
encrypted.len,
identities.as_ptr(),
2,
&mut decrypted,
);
assert_eq!(result, AgeResult::InvalidInput);
age_free_buffer(&mut encrypted);
age_free_keypair(&mut keypair);
}
#[test]
fn test_decrypt_multi_with_comments_and_empty() {
let mut keypair = AgeKeypair::null();
age_generate_x25519(&mut keypair);
let plaintext = b"test with comments";
let mut encrypted = AgeBuffer::null();
age_encrypt(plaintext.as_ptr(), plaintext.len(), keypair.public_key, &mut encrypted);
// Mix of comments, empty strings, and valid identity
let comment = CString::new("# This is a comment").unwrap();
let empty = CString::new("").unwrap();
let identities: [*const c_char; 3] = [
comment.as_ptr(),
empty.as_ptr(),
keypair.private_key as *const c_char,
];
let mut decrypted = AgeBuffer::null();
let result = age_decrypt_multi(
encrypted.data,
encrypted.len,
identities.as_ptr(),
3,
&mut decrypted,
);
assert_eq!(result, AgeResult::Success);
let decrypted_slice = unsafe { std::slice::from_raw_parts(decrypted.data, decrypted.len) };
assert_eq!(decrypted_slice, plaintext);
age_free_buffer(&mut encrypted);
age_free_buffer(&mut decrypted);
age_free_keypair(&mut keypair);
}
#[test]
fn test_decrypt_multi_only_comments() {
let mut keypair = AgeKeypair::null();
age_generate_x25519(&mut keypair);
let plaintext = b"test";
let mut encrypted = AgeBuffer::null();
age_encrypt(plaintext.as_ptr(), plaintext.len(), keypair.public_key, &mut encrypted);
// Only comments and empty - no valid identities
let comment1 = CString::new("# Comment 1").unwrap();
let comment2 = CString::new("# Comment 2").unwrap();
let empty = CString::new("").unwrap();
let identities: [*const c_char; 3] = [
comment1.as_ptr(),
comment2.as_ptr(),
empty.as_ptr(),
];
let mut decrypted = AgeBuffer::null();
let result = age_decrypt_multi(
encrypted.data,
encrypted.len,
identities.as_ptr(),
3,
&mut decrypted,
);
assert_eq!(result, AgeResult::NoIdentities);
age_free_buffer(&mut encrypted);
age_free_keypair(&mut keypair);
}
#[test]
fn test_decrypt_multi_invalid_identity_format() {
let mut keypair = AgeKeypair::null();
age_generate_x25519(&mut keypair);
let plaintext = b"test";
let mut encrypted = AgeBuffer::null();
age_encrypt(plaintext.as_ptr(), plaintext.len(), keypair.public_key, &mut encrypted);
// Invalid identity (not a comment, not empty, not valid key)
let invalid = CString::new("invalid-key-format").unwrap();
let identities: [*const c_char; 1] = [invalid.as_ptr()];
let mut decrypted = AgeBuffer::null();
let result = age_decrypt_multi(
encrypted.data,
encrypted.len,
identities.as_ptr(),
1,
&mut decrypted,
);
assert_eq!(result, AgeResult::InvalidIdentity);
age_free_buffer(&mut encrypted);
age_free_keypair(&mut keypair);
}
#[test]
fn test_decrypt_multi_corrupted_ciphertext() {
let corrupted = b"not valid age encrypted data at all";
let mut keypair = AgeKeypair::null();
age_generate_x25519(&mut keypair);
let identities: [*const c_char; 1] = [keypair.private_key as *const c_char];
let mut decrypted = AgeBuffer::null();
let result = age_decrypt_multi(
corrupted.as_ptr(),
corrupted.len(),
identities.as_ptr(),
1,
&mut decrypted,
);
assert_eq!(result, AgeResult::DecryptionFailed);
age_free_keypair(&mut keypair);
}
#[test]
fn test_decrypt_multi_wrong_key_only() {
let mut keypair1 = AgeKeypair::null();
let mut keypair2 = AgeKeypair::null();
age_generate_x25519(&mut keypair1);
age_generate_x25519(&mut keypair2);
let plaintext = b"test";
let mut encrypted = AgeBuffer::null();
age_encrypt(plaintext.as_ptr(), plaintext.len(), keypair1.public_key, &mut encrypted);
// Only provide wrong key
let identities: [*const c_char; 1] = [keypair2.private_key as *const c_char];
let mut decrypted = AgeBuffer::null();
let result = age_decrypt_multi(
encrypted.data,
encrypted.len,
identities.as_ptr(),
1,
&mut decrypted,
);
assert_eq!(result, AgeResult::DecryptionFailed);
age_free_buffer(&mut encrypted);
age_free_keypair(&mut keypair1);
age_free_keypair(&mut keypair2);
}

View File

@@ -1,210 +0,0 @@
//! In-memory encryption functions.
use crate::helpers::{cstr_to_str, string_to_cstr};
use crate::types::{AgeBuffer, AgeResult};
use std::io::Write;
use std::os::raw::c_char;
/// Encrypt data in memory using a single x25519 recipient.
/// This is a simple API for common use cases.
///
/// # Arguments
/// * `plaintext` - Pointer to the plaintext data
/// * `plaintext_len` - Length of the plaintext
/// * `recipient` - The recipient public key (age1...)
/// * `output` - Pointer to receive the encrypted buffer
///
/// # Returns
/// AgeResult indicating success or failure
#[no_mangle]
pub extern "C" fn age_encrypt(
plaintext: *const u8,
plaintext_len: usize,
recipient: *const c_char,
output: *mut AgeBuffer,
) -> AgeResult {
if plaintext.is_null() || output.is_null() {
return AgeResult::InvalidInput;
}
let plaintext = unsafe { std::slice::from_raw_parts(plaintext, plaintext_len) };
let recipient_str = match unsafe { cstr_to_str(recipient) } {
Ok(s) => s,
Err(e) => return e,
};
let recipient = match recipient_str.parse::<age::x25519::Recipient>() {
Ok(r) => r,
Err(_) => return AgeResult::InvalidRecipient,
};
let encrypted = match age::encrypt(&recipient, plaintext) {
Ok(e) => e,
Err(_) => return AgeResult::EncryptionFailed,
};
unsafe {
*output = AgeBuffer::from_vec(encrypted);
}
AgeResult::Success
}
/// Encrypt data in memory using multiple recipients.
///
/// # Arguments
/// * `plaintext` - Pointer to the plaintext data
/// * `plaintext_len` - Length of the plaintext
/// * `recipients` - Array of recipient public key C strings
/// * `recipient_count` - Number of recipients
/// * `armor` - If true, output will be ASCII-armored
/// * `output` - Pointer to receive the encrypted buffer
///
/// # Returns
/// AgeResult indicating success or failure
#[no_mangle]
pub extern "C" fn age_encrypt_multi(
plaintext: *const u8,
plaintext_len: usize,
recipients: *const *const c_char,
recipient_count: usize,
armor: bool,
output: *mut AgeBuffer,
) -> AgeResult {
if plaintext.is_null() || recipients.is_null() || output.is_null() || recipient_count == 0 {
return AgeResult::InvalidInput;
}
let plaintext = unsafe { std::slice::from_raw_parts(plaintext, plaintext_len) };
let recipient_ptrs = unsafe { std::slice::from_raw_parts(recipients, recipient_count) };
let mut parsed_recipients: Vec<Box<dyn age::Recipient + Send>> = Vec::new();
for &ptr in recipient_ptrs {
let recipient_str = match unsafe { cstr_to_str(ptr) } {
Ok(s) => s.trim(),
Err(e) => return e,
};
// Try x25519 first
if let Ok(r) = recipient_str.parse::<age::x25519::Recipient>() {
parsed_recipients.push(Box::new(r));
continue;
}
// Try SSH
if let Ok(r) = recipient_str.parse::<age::ssh::Recipient>() {
parsed_recipients.push(Box::new(r));
continue;
}
return AgeResult::InvalidRecipient;
}
if parsed_recipients.is_empty() {
return AgeResult::NoRecipients;
}
let encryptor = match age::Encryptor::with_recipients(
parsed_recipients.iter().map(|r| r.as_ref() as &dyn age::Recipient)
) {
Ok(e) => e,
Err(_) => return AgeResult::EncryptionFailed,
};
let mut encrypted = Vec::new();
let result = if armor {
let armor_writer = age::armor::ArmoredWriter::wrap_output(&mut encrypted, age::armor::Format::AsciiArmor)
.map_err(|_| AgeResult::ArmorError);
match armor_writer {
Ok(armor) => {
match encryptor.wrap_output(armor) {
Ok(mut writer) => {
if writer.write_all(plaintext).is_err() {
return AgeResult::EncryptionFailed;
}
match writer.finish() {
Ok(armor) => armor.finish().map_err(|_| AgeResult::ArmorError),
Err(_) => return AgeResult::EncryptionFailed,
}
}
Err(_) => return AgeResult::EncryptionFailed,
}
}
Err(e) => return e,
}
} else {
match encryptor.wrap_output(&mut encrypted) {
Ok(mut writer) => {
if writer.write_all(plaintext).is_err() {
return AgeResult::EncryptionFailed;
}
writer.finish().map_err(|_| AgeResult::EncryptionFailed)
}
Err(_) => return AgeResult::EncryptionFailed,
}
};
if result.is_err() {
return AgeResult::EncryptionFailed;
}
unsafe {
*output = AgeBuffer::from_vec(encrypted);
}
AgeResult::Success
}
/// Encrypt data with ASCII armor for text-safe output.
///
/// # Arguments
/// * `plaintext` - Pointer to the plaintext data
/// * `plaintext_len` - Length of the plaintext
/// * `recipient` - The recipient public key (age1...)
/// * `output` - Pointer to receive the armored string (null-terminated)
///
/// # Returns
/// AgeResult indicating success or failure
#[no_mangle]
pub extern "C" fn age_encrypt_armor(
plaintext: *const u8,
plaintext_len: usize,
recipient: *const c_char,
output: *mut *mut c_char,
) -> AgeResult {
if plaintext.is_null() || output.is_null() {
return AgeResult::InvalidInput;
}
let plaintext = unsafe { std::slice::from_raw_parts(plaintext, plaintext_len) };
let recipient_str = match unsafe { cstr_to_str(recipient) } {
Ok(s) => s,
Err(e) => return e,
};
let recipient = match recipient_str.parse::<age::x25519::Recipient>() {
Ok(r) => r,
Err(_) => return AgeResult::InvalidRecipient,
};
let encrypted = match age::encrypt_and_armor(&recipient, plaintext) {
Ok(e) => e,
Err(_) => return AgeResult::EncryptionFailed,
};
let c_output = match string_to_cstr(encrypted) {
Ok(s) => s,
Err(e) => return e,
};
unsafe {
*output = c_output;
}
AgeResult::Success
}

View File

@@ -1,232 +0,0 @@
//! Tests for in-memory encryption functions.
use crate::encrypt::*;
use crate::decrypt::*;
use crate::keys::*;
use crate::memory::*;
use crate::types::*;
use std::ffi::CString;
use std::os::raw::c_char;
#[test]
fn test_basic_encrypt() {
let mut keypair = AgeKeypair::null();
age_generate_x25519(&mut keypair);
let plaintext = b"Hello, encryption!";
let mut encrypted = AgeBuffer::null();
let result = age_encrypt(
plaintext.as_ptr(),
plaintext.len(),
keypair.public_key,
&mut encrypted,
);
assert_eq!(result, AgeResult::Success);
assert!(!encrypted.data.is_null());
assert!(encrypted.len > plaintext.len()); // Encrypted should be larger
age_free_buffer(&mut encrypted);
age_free_keypair(&mut keypair);
}
#[test]
fn test_encrypt_null_plaintext() {
let mut keypair = AgeKeypair::null();
age_generate_x25519(&mut keypair);
let mut encrypted = AgeBuffer::null();
let result = age_encrypt(
std::ptr::null(),
0,
keypair.public_key,
&mut encrypted,
);
assert_eq!(result, AgeResult::InvalidInput);
age_free_keypair(&mut keypair);
}
#[test]
fn test_encrypt_null_output() {
let mut keypair = AgeKeypair::null();
age_generate_x25519(&mut keypair);
let plaintext = b"test";
let result = age_encrypt(
plaintext.as_ptr(),
plaintext.len(),
keypair.public_key,
std::ptr::null_mut(),
);
assert_eq!(result, AgeResult::InvalidInput);
age_free_keypair(&mut keypair);
}
#[test]
fn test_encrypt_invalid_recipient() {
let invalid_recipient = CString::new("not-a-valid-recipient").unwrap();
let plaintext = b"test";
let mut encrypted = AgeBuffer::null();
let result = age_encrypt(
plaintext.as_ptr(),
plaintext.len(),
invalid_recipient.as_ptr(),
&mut encrypted,
);
assert_eq!(result, AgeResult::InvalidRecipient);
}
#[test]
fn test_encrypt_multi_two_recipients() {
let mut keypair1 = AgeKeypair::null();
let mut keypair2 = AgeKeypair::null();
age_generate_x25519(&mut keypair1);
age_generate_x25519(&mut keypair2);
let plaintext = b"Message for both recipients";
let recipients: [*const c_char; 2] = [
keypair1.public_key as *const c_char,
keypair2.public_key as *const c_char,
];
let mut encrypted = AgeBuffer::null();
let result = age_encrypt_multi(
plaintext.as_ptr(),
plaintext.len(),
recipients.as_ptr(),
2,
false,
&mut encrypted,
);
assert_eq!(result, AgeResult::Success);
// Both recipients should be able to decrypt
let mut decrypted1 = AgeBuffer::null();
let result = age_decrypt(encrypted.data, encrypted.len, keypair1.private_key, &mut decrypted1);
assert_eq!(result, AgeResult::Success);
let mut decrypted2 = AgeBuffer::null();
let result = age_decrypt(encrypted.data, encrypted.len, keypair2.private_key, &mut decrypted2);
assert_eq!(result, AgeResult::Success);
age_free_buffer(&mut encrypted);
age_free_buffer(&mut decrypted1);
age_free_buffer(&mut decrypted2);
age_free_keypair(&mut keypair1);
age_free_keypair(&mut keypair2);
}
#[test]
fn test_encrypt_multi_with_armor() {
let mut keypair = AgeKeypair::null();
age_generate_x25519(&mut keypair);
let plaintext = b"Armored multi-recipient message";
let recipients: [*const c_char; 1] = [keypair.public_key as *const c_char];
let mut encrypted = AgeBuffer::null();
let result = age_encrypt_multi(
plaintext.as_ptr(),
plaintext.len(),
recipients.as_ptr(),
1,
true, // armor
&mut encrypted,
);
assert_eq!(result, AgeResult::Success);
// Check it's armored
let encrypted_slice = unsafe { std::slice::from_raw_parts(encrypted.data, encrypted.len) };
let encrypted_str = std::str::from_utf8(encrypted_slice).unwrap();
assert!(encrypted_str.contains("-----BEGIN AGE ENCRYPTED FILE-----"));
age_free_buffer(&mut encrypted);
age_free_keypair(&mut keypair);
}
#[test]
fn test_encrypt_multi_empty_recipients() {
let plaintext = b"test";
let mut encrypted = AgeBuffer::null();
let result = age_encrypt_multi(
plaintext.as_ptr(),
plaintext.len(),
std::ptr::null(),
0,
false,
&mut encrypted,
);
assert_eq!(result, AgeResult::InvalidInput);
}
#[test]
fn test_encrypt_armor() {
let mut keypair = AgeKeypair::null();
age_generate_x25519(&mut keypair);
let plaintext = b"Armored message";
let mut armored: *mut c_char = std::ptr::null_mut();
let result = age_encrypt_armor(
plaintext.as_ptr(),
plaintext.len(),
keypair.public_key,
&mut armored,
);
assert_eq!(result, AgeResult::Success);
assert!(!armored.is_null());
let armored_str = unsafe { std::ffi::CStr::from_ptr(armored).to_str().unwrap() };
assert!(armored_str.starts_with("-----BEGIN AGE ENCRYPTED FILE-----"));
assert!(armored_str.contains("-----END AGE ENCRYPTED FILE-----"));
age_free_string(armored);
age_free_keypair(&mut keypair);
}
#[test]
fn test_encrypt_various_sizes() {
let mut keypair = AgeKeypair::null();
age_generate_x25519(&mut keypair);
let sizes = [0, 1, 16, 256, 1024, 4096, 65536];
for size in sizes {
let plaintext: Vec<u8> = (0..size).map(|i| (i % 256) as u8).collect();
let mut encrypted = AgeBuffer::null();
let result = age_encrypt(
plaintext.as_ptr(),
plaintext.len(),
keypair.public_key,
&mut encrypted,
);
assert_eq!(result, AgeResult::Success, "Failed for size {}", size);
// Verify we can decrypt it back
let mut decrypted = AgeBuffer::null();
let result = age_decrypt(encrypted.data, encrypted.len, keypair.private_key, &mut decrypted);
assert_eq!(result, AgeResult::Success, "Decrypt failed for size {}", size);
let decrypted_slice = unsafe { std::slice::from_raw_parts(decrypted.data, decrypted.len) };
assert_eq!(decrypted_slice, plaintext.as_slice(), "Mismatch for size {}", size);
age_free_buffer(&mut encrypted);
age_free_buffer(&mut decrypted);
}
age_free_keypair(&mut keypair);
}

View File

@@ -1,351 +0,0 @@
//! File-based encryption and decryption operations.
use crate::helpers::{cstr_to_str, cstr_to_string};
use crate::types::{AgeBuffer, AgeResult};
use age::secrecy::SecretString;
use std::fs::File;
use std::io::{Read, Write};
use std::os::raw::c_char;
use std::str::FromStr;
/// Encrypt data to a file using a recipient.
///
/// # Arguments
/// * `plaintext` - The data to encrypt
/// * `plaintext_len` - Length of the plaintext
/// * `output_path` - Path to write the encrypted .age file
/// * `recipient` - The recipient public key (age1...) or path to recipients file
///
/// # Returns
/// AgeResult indicating success or failure
#[no_mangle]
pub extern "C" fn age_encrypt_to_file(
plaintext: *const c_char,
plaintext_len: usize,
output_path: *const c_char,
recipient: *const c_char,
) -> AgeResult {
if plaintext.is_null() || output_path.is_null() || recipient.is_null() {
return AgeResult::InvalidInput;
}
let plaintext = unsafe { std::slice::from_raw_parts(plaintext as *const u8, plaintext_len) };
let output_path = match unsafe { cstr_to_str(output_path) } {
Ok(s) => s,
Err(e) => return e,
};
let recipient_str = match unsafe { cstr_to_str(recipient) } {
Ok(s) => s,
Err(e) => return e,
};
// Parse recipients - could be a file path or a direct recipient key
// Supports: x25519 (age1...), plugin (age1<plugin>1...), and ssh (ssh-...)
let mut recipients: Vec<Box<dyn age::Recipient + Send>> = Vec::new();
let mut plugin_recipients: Vec<age::plugin::Recipient> = Vec::new();
let recipient_lines: Vec<&str> = if recipient_str.starts_with("age1") || recipient_str.starts_with("ssh-") {
vec![recipient_str]
} else {
// Assume it's a file path containing recipients
match std::fs::read_to_string(recipient_str) {
Ok(contents) => {
// We need to own the string for the lines
let contents_leaked: &'static str = Box::leak(contents.into_boxed_str());
contents_leaked
.lines()
.filter(|line| !line.starts_with('#') && !line.is_empty())
.map(|line| line.trim())
.collect()
}
Err(_) => return AgeResult::IoError,
}
};
for line in recipient_lines {
// Try x25519 first
if let Ok(r) = line.parse::<age::x25519::Recipient>() {
recipients.push(Box::new(r));
continue;
}
// Then try plugin recipient - collect these separately
if let Ok(r) = line.parse::<age::plugin::Recipient>() {
plugin_recipients.push(r);
continue;
}
// Finally try SSH
if let Ok(r) = line.parse::<age::ssh::Recipient>() {
recipients.push(Box::new(r));
continue;
}
// Skip unrecognized lines
}
// Create plugin recipients wrapper if we have any plugin recipients
// Group them by plugin name
if !plugin_recipients.is_empty() {
use std::collections::HashMap;
let mut by_plugin: HashMap<String, Vec<age::plugin::Recipient>> = HashMap::new();
for r in plugin_recipients {
by_plugin.entry(r.plugin().to_string()).or_default().push(r);
}
for (plugin_name, plugin_recs) in by_plugin {
match age::plugin::RecipientPluginV1::new(
&plugin_name,
&plugin_recs,
&[],
age::NoCallbacks,
) {
Ok(plugin) => recipients.push(Box::new(plugin)),
Err(_) => return AgeResult::InvalidRecipient,
}
}
}
if recipients.is_empty() {
return AgeResult::InvalidRecipient;
}
let output_file = match File::create(output_path) {
Ok(f) => f,
Err(_) => return AgeResult::IoError,
};
let encryptor = match age::Encryptor::with_recipients(recipients.iter().map(|r| r.as_ref() as &dyn age::Recipient)) {
Ok(e) => e,
Err(_) => return AgeResult::EncryptionFailed,
};
let mut writer = match encryptor.wrap_output(output_file) {
Ok(w) => w,
Err(_) => return AgeResult::EncryptionFailed,
};
if writer.write_all(plaintext).is_err() {
return AgeResult::EncryptionFailed;
}
if writer.finish().is_err() {
return AgeResult::EncryptionFailed;
}
AgeResult::Success
}
/// Encrypt data to a file with ASCII armor.
#[no_mangle]
pub extern "C" fn age_encrypt_to_file_armor(
plaintext: *const u8,
plaintext_len: usize,
output_path: *const c_char,
recipient: *const c_char,
) -> AgeResult {
if plaintext.is_null() || output_path.is_null() {
return AgeResult::InvalidInput;
}
let plaintext = unsafe { std::slice::from_raw_parts(plaintext, plaintext_len) };
let output_path = match unsafe { cstr_to_str(output_path) } {
Ok(s) => s,
Err(e) => return e,
};
let recipient_str = match unsafe { cstr_to_str(recipient) } {
Ok(s) => s,
Err(e) => return e,
};
let recipient = match recipient_str.parse::<age::x25519::Recipient>() {
Ok(r) => r,
Err(_) => return AgeResult::InvalidRecipient,
};
let encrypted = match age::encrypt_and_armor(&recipient, plaintext) {
Ok(e) => e,
Err(_) => return AgeResult::EncryptionFailed,
};
if std::fs::write(output_path, encrypted).is_err() {
return AgeResult::IoError;
}
AgeResult::Success
}
/// Decrypt data from a file using an identity file.
///
/// This function supports all identity types including:
/// - Standard x25519 identities (AGE-SECRET-KEY-...)
/// - SSH identities
/// - Plugin identities (AGE-PLUGIN-...)
#[no_mangle]
pub extern "C" fn age_decrypt_file(
encrypted_path: *const c_char,
identity_path: *const c_char,
output: *mut AgeBuffer,
) -> AgeResult {
if output.is_null() {
return AgeResult::InvalidInput;
}
let encrypted_path = match unsafe { cstr_to_str(encrypted_path) } {
Ok(s) => s,
Err(e) => return e,
};
let identity_path = match unsafe { cstr_to_str(identity_path) } {
Ok(s) => s,
Err(e) => return e,
};
// Use IdentityFile to parse the identity file - this supports all identity types
// including plugin identities (AGE-PLUGIN-...)
let identity_file = match age::IdentityFile::from_file(identity_path.to_string()) {
Ok(f) => f,
Err(_) => return AgeResult::IoError,
};
// Get all identities from the file
let identities = match identity_file.into_identities() {
Ok(ids) => ids,
Err(_) => return AgeResult::InvalidIdentity,
};
if identities.is_empty() {
return AgeResult::InvalidIdentity;
}
let encrypted_file = match File::open(encrypted_path) {
Ok(f) => f,
Err(_) => return AgeResult::IoError,
};
let decryptor = match age::Decryptor::new(encrypted_file) {
Ok(d) => d,
Err(_) => return AgeResult::DecryptionFailed,
};
let mut decrypted = Vec::new();
let mut reader = match decryptor.decrypt(identities.iter().map(|i| i.as_ref() as &dyn age::Identity)) {
Ok(r) => r,
Err(_) => return AgeResult::DecryptionFailed,
};
if reader.read_to_end(&mut decrypted).is_err() {
return AgeResult::DecryptionFailed;
}
unsafe {
*output = AgeBuffer::from_vec(decrypted);
}
AgeResult::Success
}
/// Decrypt data from a file using a single identity string.
#[no_mangle]
pub extern "C" fn age_decrypt_file_with_identity(
encrypted_path: *const c_char,
identity: *const c_char,
output: *mut AgeBuffer,
) -> AgeResult {
if output.is_null() {
return AgeResult::InvalidInput;
}
let encrypted_path = match unsafe { cstr_to_str(encrypted_path) } {
Ok(s) => s,
Err(e) => return e,
};
let identity_str = match unsafe { cstr_to_str(identity) } {
Ok(s) => s,
Err(e) => return e,
};
let identity = match age::x25519::Identity::from_str(identity_str) {
Ok(i) => i,
Err(_) => return AgeResult::InvalidIdentity,
};
let encrypted_file = match File::open(encrypted_path) {
Ok(f) => f,
Err(_) => return AgeResult::IoError,
};
let decryptor = match age::Decryptor::new(encrypted_file) {
Ok(d) => d,
Err(_) => return AgeResult::DecryptionFailed,
};
let mut decrypted = Vec::new();
let mut reader = match decryptor.decrypt(std::iter::once(&identity as &dyn age::Identity)) {
Ok(r) => r,
Err(_) => return AgeResult::DecryptionFailed,
};
if reader.read_to_end(&mut decrypted).is_err() {
return AgeResult::DecryptionFailed;
}
unsafe {
*output = AgeBuffer::from_vec(decrypted);
}
AgeResult::Success
}
/// Decrypt a file using a passphrase.
#[no_mangle]
pub extern "C" fn age_decrypt_file_passphrase(
encrypted_path: *const c_char,
passphrase: *const c_char,
output: *mut AgeBuffer,
) -> AgeResult {
if output.is_null() {
return AgeResult::InvalidInput;
}
let encrypted_path = match unsafe { cstr_to_str(encrypted_path) } {
Ok(s) => s,
Err(e) => return e,
};
let passphrase_str = match unsafe { cstr_to_string(passphrase) } {
Ok(s) => s,
Err(e) => return e,
};
let secret = SecretString::from(passphrase_str);
let identity = age::scrypt::Identity::new(secret);
let encrypted_file = match File::open(encrypted_path) {
Ok(f) => f,
Err(_) => return AgeResult::IoError,
};
let decryptor = match age::Decryptor::new(encrypted_file) {
Ok(d) => d,
Err(_) => return AgeResult::DecryptionFailed,
};
let mut decrypted = Vec::new();
let mut reader = match decryptor.decrypt(std::iter::once(&identity as &dyn age::Identity)) {
Ok(r) => r,
Err(_) => return AgeResult::DecryptionFailed,
};
if reader.read_to_end(&mut decrypted).is_err() {
return AgeResult::DecryptionFailed;
}
unsafe {
*output = AgeBuffer::from_vec(decrypted);
}
AgeResult::Success
}

View File

@@ -1,808 +0,0 @@
//! Tests for file-based encryption and decryption functions.
use crate::file::*;
use crate::keys::*;
use crate::memory::*;
use crate::passphrase::*;
use crate::types::*;
use std::ffi::CString;
use std::fs;
use std::io::Write;
fn create_temp_file(suffix: &str) -> String {
let temp_dir = std::env::temp_dir();
let unique_id = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos();
format!("{}/age_test_{}_{}", temp_dir.display(), unique_id, suffix)
}
// ============= age_encrypt_to_file tests =============
#[test]
fn test_encrypt_to_file_basic() {
let mut keypair = AgeKeypair::null();
age_generate_x25519(&mut keypair);
let plaintext = b"Hello, file encryption!";
let output_path = create_temp_file("encrypted.age");
let output_path_c = CString::new(output_path.as_str()).unwrap();
let result = age_encrypt_to_file(
plaintext.as_ptr() as *const i8,
plaintext.len(),
output_path_c.as_ptr(),
keypair.public_key,
);
assert_eq!(result, AgeResult::Success);
assert!(std::path::Path::new(&output_path).exists());
// Clean up
fs::remove_file(&output_path).ok();
age_free_keypair(&mut keypair);
}
#[test]
fn test_encrypt_to_file_null_plaintext() {
let output_path = create_temp_file("test.age");
let output_path_c = CString::new(output_path.as_str()).unwrap();
let recipient = CString::new("age1test").unwrap();
let result = age_encrypt_to_file(
std::ptr::null(),
0,
output_path_c.as_ptr(),
recipient.as_ptr(),
);
assert_eq!(result, AgeResult::InvalidInput);
}
#[test]
fn test_encrypt_to_file_null_output_path() {
let mut keypair = AgeKeypair::null();
age_generate_x25519(&mut keypair);
let plaintext = b"test";
let result = age_encrypt_to_file(
plaintext.as_ptr() as *const i8,
plaintext.len(),
std::ptr::null(),
keypair.public_key,
);
assert_eq!(result, AgeResult::InvalidInput);
age_free_keypair(&mut keypair);
}
#[test]
fn test_encrypt_to_file_null_recipient() {
let plaintext = b"test";
let output_path = create_temp_file("test.age");
let output_path_c = CString::new(output_path.as_str()).unwrap();
let result = age_encrypt_to_file(
plaintext.as_ptr() as *const i8,
plaintext.len(),
output_path_c.as_ptr(),
std::ptr::null(),
);
assert_eq!(result, AgeResult::InvalidInput);
}
#[test]
fn test_encrypt_to_file_invalid_recipient() {
let plaintext = b"test";
let output_path = create_temp_file("test.age");
let output_path_c = CString::new(output_path.as_str()).unwrap();
let invalid_recipient = CString::new("age1invalid_not_a_real_key").unwrap();
let result = age_encrypt_to_file(
plaintext.as_ptr() as *const i8,
plaintext.len(),
output_path_c.as_ptr(),
invalid_recipient.as_ptr(),
);
assert_eq!(result, AgeResult::InvalidRecipient);
}
#[test]
fn test_encrypt_to_file_and_decrypt_with_identity() {
let mut keypair = AgeKeypair::null();
age_generate_x25519(&mut keypair);
let plaintext = b"Round trip file encryption test!";
let output_path = create_temp_file("roundtrip.age");
let output_path_c = CString::new(output_path.as_str()).unwrap();
// Encrypt to file
let result = age_encrypt_to_file(
plaintext.as_ptr() as *const i8,
plaintext.len(),
output_path_c.as_ptr(),
keypair.public_key,
);
assert_eq!(result, AgeResult::Success);
// Decrypt with identity string
let mut output = AgeBuffer::null();
let result = age_decrypt_file_with_identity(
output_path_c.as_ptr(),
keypair.private_key,
&mut output,
);
assert_eq!(result, AgeResult::Success);
let decrypted = unsafe { std::slice::from_raw_parts(output.data, output.len) };
assert_eq!(decrypted, plaintext);
// Clean up
fs::remove_file(&output_path).ok();
age_free_buffer(&mut output);
age_free_keypair(&mut keypair);
}
// ============= age_encrypt_to_file_armor tests =============
#[test]
fn test_encrypt_to_file_armor_basic() {
let mut keypair = AgeKeypair::null();
age_generate_x25519(&mut keypair);
let plaintext = b"Armored file test";
let output_path = create_temp_file("armored.age");
let output_path_c = CString::new(output_path.as_str()).unwrap();
let result = age_encrypt_to_file_armor(
plaintext.as_ptr(),
plaintext.len(),
output_path_c.as_ptr(),
keypair.public_key,
);
assert_eq!(result, AgeResult::Success);
// Verify the file is armored
let contents = fs::read_to_string(&output_path).unwrap();
assert!(contents.contains("-----BEGIN AGE ENCRYPTED FILE-----"));
assert!(contents.contains("-----END AGE ENCRYPTED FILE-----"));
// Clean up
fs::remove_file(&output_path).ok();
age_free_keypair(&mut keypair);
}
#[test]
fn test_encrypt_to_file_armor_null_plaintext() {
let output_path = create_temp_file("test.age");
let output_path_c = CString::new(output_path.as_str()).unwrap();
let recipient = CString::new("age1test").unwrap();
let result = age_encrypt_to_file_armor(
std::ptr::null(),
0,
output_path_c.as_ptr(),
recipient.as_ptr(),
);
assert_eq!(result, AgeResult::InvalidInput);
}
#[test]
fn test_encrypt_to_file_armor_null_output_path() {
let mut keypair = AgeKeypair::null();
age_generate_x25519(&mut keypair);
let plaintext = b"test";
let result = age_encrypt_to_file_armor(
plaintext.as_ptr(),
plaintext.len(),
std::ptr::null(),
keypair.public_key,
);
assert_eq!(result, AgeResult::InvalidInput);
age_free_keypair(&mut keypair);
}
#[test]
fn test_encrypt_to_file_armor_invalid_recipient() {
let plaintext = b"test";
let output_path = create_temp_file("test.age");
let output_path_c = CString::new(output_path.as_str()).unwrap();
let invalid_recipient = CString::new("not-a-recipient").unwrap();
let result = age_encrypt_to_file_armor(
plaintext.as_ptr(),
plaintext.len(),
output_path_c.as_ptr(),
invalid_recipient.as_ptr(),
);
assert_eq!(result, AgeResult::InvalidRecipient);
}
// ============= age_decrypt_file tests =============
#[test]
fn test_decrypt_file_basic() {
let mut keypair = AgeKeypair::null();
age_generate_x25519(&mut keypair);
let plaintext = b"Decrypt from identity file test";
// Create encrypted file
let encrypted_path = create_temp_file("encrypted.age");
let encrypted_path_c = CString::new(encrypted_path.as_str()).unwrap();
let result = age_encrypt_to_file(
plaintext.as_ptr() as *const i8,
plaintext.len(),
encrypted_path_c.as_ptr(),
keypair.public_key,
);
assert_eq!(result, AgeResult::Success);
// Create identity file
let identity_path = create_temp_file("identity.txt");
let private_key = unsafe { std::ffi::CStr::from_ptr(keypair.private_key).to_str().unwrap() };
fs::write(&identity_path, private_key).unwrap();
let identity_path_c = CString::new(identity_path.as_str()).unwrap();
// Decrypt
let mut output = AgeBuffer::null();
let result = age_decrypt_file(
encrypted_path_c.as_ptr(),
identity_path_c.as_ptr(),
&mut output,
);
assert_eq!(result, AgeResult::Success);
let decrypted = unsafe { std::slice::from_raw_parts(output.data, output.len) };
assert_eq!(decrypted, plaintext);
// Clean up
fs::remove_file(&encrypted_path).ok();
fs::remove_file(&identity_path).ok();
age_free_buffer(&mut output);
age_free_keypair(&mut keypair);
}
#[test]
fn test_decrypt_file_null_output() {
let encrypted_path = CString::new("/tmp/test.age").unwrap();
let identity_path = CString::new("/tmp/identity.txt").unwrap();
let result = age_decrypt_file(
encrypted_path.as_ptr(),
identity_path.as_ptr(),
std::ptr::null_mut(),
);
assert_eq!(result, AgeResult::InvalidInput);
}
#[test]
fn test_decrypt_file_null_encrypted_path() {
let identity_path = CString::new("/tmp/identity.txt").unwrap();
let mut output = AgeBuffer::null();
let result = age_decrypt_file(
std::ptr::null(),
identity_path.as_ptr(),
&mut output,
);
assert_eq!(result, AgeResult::InvalidInput);
}
#[test]
fn test_decrypt_file_nonexistent_identity_file() {
let mut keypair = AgeKeypair::null();
age_generate_x25519(&mut keypair);
// Create a real encrypted file
let plaintext = b"test";
let encrypted_path = create_temp_file("test_enc.age");
let encrypted_path_c = CString::new(encrypted_path.as_str()).unwrap();
let result = age_encrypt_to_file(
plaintext.as_ptr() as *const i8,
plaintext.len(),
encrypted_path_c.as_ptr(),
keypair.public_key,
);
assert_eq!(result, AgeResult::Success);
// Try to decrypt with nonexistent identity file
let identity_path = CString::new("/nonexistent/identity.txt").unwrap();
let mut output = AgeBuffer::null();
let result = age_decrypt_file(
encrypted_path_c.as_ptr(),
identity_path.as_ptr(),
&mut output,
);
assert_eq!(result, AgeResult::IoError);
fs::remove_file(&encrypted_path).ok();
age_free_keypair(&mut keypair);
}
#[test]
fn test_decrypt_file_nonexistent_encrypted_file() {
// Create a valid identity file
let mut keypair = AgeKeypair::null();
age_generate_x25519(&mut keypair);
let identity_path = create_temp_file("identity.txt");
let private_key = unsafe { std::ffi::CStr::from_ptr(keypair.private_key).to_str().unwrap() };
fs::write(&identity_path, private_key).unwrap();
let identity_path_c = CString::new(identity_path.as_str()).unwrap();
let encrypted_path = CString::new("/nonexistent/encrypted.age").unwrap();
let mut output = AgeBuffer::null();
let result = age_decrypt_file(
encrypted_path.as_ptr(),
identity_path_c.as_ptr(),
&mut output,
);
assert_eq!(result, AgeResult::IoError);
fs::remove_file(&identity_path).ok();
age_free_keypair(&mut keypair);
}
#[test]
fn test_decrypt_file_empty_identity_file() {
let mut keypair = AgeKeypair::null();
age_generate_x25519(&mut keypair);
// Create encrypted file
let plaintext = b"test";
let encrypted_path = create_temp_file("enc.age");
let encrypted_path_c = CString::new(encrypted_path.as_str()).unwrap();
let result = age_encrypt_to_file(
plaintext.as_ptr() as *const i8,
plaintext.len(),
encrypted_path_c.as_ptr(),
keypair.public_key,
);
assert_eq!(result, AgeResult::Success);
// Create empty identity file
let identity_path = create_temp_file("empty_identity.txt");
fs::write(&identity_path, "").unwrap();
let identity_path_c = CString::new(identity_path.as_str()).unwrap();
let mut output = AgeBuffer::null();
let result = age_decrypt_file(
encrypted_path_c.as_ptr(),
identity_path_c.as_ptr(),
&mut output,
);
assert_eq!(result, AgeResult::InvalidIdentity);
fs::remove_file(&encrypted_path).ok();
fs::remove_file(&identity_path).ok();
age_free_keypair(&mut keypair);
}
#[test]
fn test_decrypt_file_with_comments_in_identity() {
let mut keypair = AgeKeypair::null();
age_generate_x25519(&mut keypair);
// Create encrypted file
let plaintext = b"test with comments";
let encrypted_path = create_temp_file("enc_comments.age");
let encrypted_path_c = CString::new(encrypted_path.as_str()).unwrap();
let result = age_encrypt_to_file(
plaintext.as_ptr() as *const i8,
plaintext.len(),
encrypted_path_c.as_ptr(),
keypair.public_key,
);
assert_eq!(result, AgeResult::Success);
// Create identity file with comments
let identity_path = create_temp_file("identity_with_comments.txt");
let private_key = unsafe { std::ffi::CStr::from_ptr(keypair.private_key).to_str().unwrap() };
let content = format!("# This is a comment\n\n{}\n# Another comment", private_key);
fs::write(&identity_path, content).unwrap();
let identity_path_c = CString::new(identity_path.as_str()).unwrap();
let mut output = AgeBuffer::null();
let result = age_decrypt_file(
encrypted_path_c.as_ptr(),
identity_path_c.as_ptr(),
&mut output,
);
assert_eq!(result, AgeResult::Success);
let decrypted = unsafe { std::slice::from_raw_parts(output.data, output.len) };
assert_eq!(decrypted, plaintext);
fs::remove_file(&encrypted_path).ok();
fs::remove_file(&identity_path).ok();
age_free_buffer(&mut output);
age_free_keypair(&mut keypair);
}
// ============= age_decrypt_file_with_identity tests =============
#[test]
fn test_decrypt_file_with_identity_null_output() {
let encrypted_path = CString::new("/tmp/test.age").unwrap();
let identity = CString::new("AGE-SECRET-KEY-1TEST").unwrap();
let result = age_decrypt_file_with_identity(
encrypted_path.as_ptr(),
identity.as_ptr(),
std::ptr::null_mut(),
);
assert_eq!(result, AgeResult::InvalidInput);
}
#[test]
fn test_decrypt_file_with_identity_null_path() {
let identity = CString::new("AGE-SECRET-KEY-1TEST").unwrap();
let mut output = AgeBuffer::null();
let result = age_decrypt_file_with_identity(
std::ptr::null(),
identity.as_ptr(),
&mut output,
);
assert_eq!(result, AgeResult::InvalidInput);
}
#[test]
fn test_decrypt_file_with_identity_invalid_identity() {
let mut keypair = AgeKeypair::null();
age_generate_x25519(&mut keypair);
// Create encrypted file
let plaintext = b"test";
let encrypted_path = create_temp_file("enc_invalid_id.age");
let encrypted_path_c = CString::new(encrypted_path.as_str()).unwrap();
let result = age_encrypt_to_file(
plaintext.as_ptr() as *const i8,
plaintext.len(),
encrypted_path_c.as_ptr(),
keypair.public_key,
);
assert_eq!(result, AgeResult::Success);
let invalid_identity = CString::new("not-a-valid-identity").unwrap();
let mut output = AgeBuffer::null();
let result = age_decrypt_file_with_identity(
encrypted_path_c.as_ptr(),
invalid_identity.as_ptr(),
&mut output,
);
assert_eq!(result, AgeResult::InvalidIdentity);
fs::remove_file(&encrypted_path).ok();
age_free_keypair(&mut keypair);
}
#[test]
fn test_decrypt_file_with_identity_wrong_key() {
let mut keypair1 = AgeKeypair::null();
let mut keypair2 = AgeKeypair::null();
age_generate_x25519(&mut keypair1);
age_generate_x25519(&mut keypair2);
// Encrypt with keypair1
let plaintext = b"secret message";
let encrypted_path = create_temp_file("wrong_key.age");
let encrypted_path_c = CString::new(encrypted_path.as_str()).unwrap();
let result = age_encrypt_to_file(
plaintext.as_ptr() as *const i8,
plaintext.len(),
encrypted_path_c.as_ptr(),
keypair1.public_key,
);
assert_eq!(result, AgeResult::Success);
// Try to decrypt with keypair2
let mut output = AgeBuffer::null();
let result = age_decrypt_file_with_identity(
encrypted_path_c.as_ptr(),
keypair2.private_key,
&mut output,
);
assert_eq!(result, AgeResult::DecryptionFailed);
fs::remove_file(&encrypted_path).ok();
age_free_keypair(&mut keypair1);
age_free_keypair(&mut keypair2);
}
#[test]
fn test_decrypt_file_with_identity_nonexistent_file() {
let mut keypair = AgeKeypair::null();
age_generate_x25519(&mut keypair);
let encrypted_path = CString::new("/nonexistent/file.age").unwrap();
let mut output = AgeBuffer::null();
let result = age_decrypt_file_with_identity(
encrypted_path.as_ptr(),
keypair.private_key,
&mut output,
);
assert_eq!(result, AgeResult::IoError);
age_free_keypair(&mut keypair);
}
// ============= age_decrypt_file_passphrase tests =============
#[test]
fn test_decrypt_file_passphrase_basic() {
let passphrase = CString::new("mysecretpassword").unwrap();
let plaintext = b"Passphrase protected content";
// Encrypt with passphrase first (using in-memory function)
let mut encrypted = AgeBuffer::null();
let result = age_encrypt_passphrase(
plaintext.as_ptr(),
plaintext.len(),
passphrase.as_ptr(),
false,
&mut encrypted,
);
assert_eq!(result, AgeResult::Success);
// Write encrypted content to file
let encrypted_path = create_temp_file("passphrase.age");
let encrypted_slice = unsafe { std::slice::from_raw_parts(encrypted.data, encrypted.len) };
fs::write(&encrypted_path, encrypted_slice).unwrap();
let encrypted_path_c = CString::new(encrypted_path.as_str()).unwrap();
// Decrypt file with passphrase
let mut output = AgeBuffer::null();
let result = age_decrypt_file_passphrase(
encrypted_path_c.as_ptr(),
passphrase.as_ptr(),
&mut output,
);
assert_eq!(result, AgeResult::Success);
let decrypted = unsafe { std::slice::from_raw_parts(output.data, output.len) };
assert_eq!(decrypted, plaintext);
// Clean up
fs::remove_file(&encrypted_path).ok();
age_free_buffer(&mut encrypted);
age_free_buffer(&mut output);
}
#[test]
fn test_decrypt_file_passphrase_null_output() {
let encrypted_path = CString::new("/tmp/test.age").unwrap();
let passphrase = CString::new("password").unwrap();
let result = age_decrypt_file_passphrase(
encrypted_path.as_ptr(),
passphrase.as_ptr(),
std::ptr::null_mut(),
);
assert_eq!(result, AgeResult::InvalidInput);
}
#[test]
fn test_decrypt_file_passphrase_null_path() {
let passphrase = CString::new("password").unwrap();
let mut output = AgeBuffer::null();
let result = age_decrypt_file_passphrase(
std::ptr::null(),
passphrase.as_ptr(),
&mut output,
);
assert_eq!(result, AgeResult::InvalidInput);
}
#[test]
fn test_decrypt_file_passphrase_wrong_passphrase() {
let passphrase = CString::new("correctpassword").unwrap();
let wrong_passphrase = CString::new("wrongpassword").unwrap();
let plaintext = b"Secret content";
// Encrypt with passphrase
let mut encrypted = AgeBuffer::null();
let result = age_encrypt_passphrase(
plaintext.as_ptr(),
plaintext.len(),
passphrase.as_ptr(),
false,
&mut encrypted,
);
assert_eq!(result, AgeResult::Success);
// Write to file
let encrypted_path = create_temp_file("wrong_pass.age");
let encrypted_slice = unsafe { std::slice::from_raw_parts(encrypted.data, encrypted.len) };
fs::write(&encrypted_path, encrypted_slice).unwrap();
let encrypted_path_c = CString::new(encrypted_path.as_str()).unwrap();
// Try to decrypt with wrong passphrase
let mut output = AgeBuffer::null();
let result = age_decrypt_file_passphrase(
encrypted_path_c.as_ptr(),
wrong_passphrase.as_ptr(),
&mut output,
);
assert_eq!(result, AgeResult::DecryptionFailed);
// Clean up
fs::remove_file(&encrypted_path).ok();
age_free_buffer(&mut encrypted);
}
#[test]
fn test_decrypt_file_passphrase_nonexistent_file() {
let passphrase = CString::new("password").unwrap();
let encrypted_path = CString::new("/nonexistent/passphrase.age").unwrap();
let mut output = AgeBuffer::null();
let result = age_decrypt_file_passphrase(
encrypted_path.as_ptr(),
passphrase.as_ptr(),
&mut output,
);
assert_eq!(result, AgeResult::IoError);
}
// ============= Recipient file tests =============
#[test]
fn test_encrypt_to_file_with_recipients_file() {
let mut keypair1 = AgeKeypair::null();
let mut keypair2 = AgeKeypair::null();
age_generate_x25519(&mut keypair1);
age_generate_x25519(&mut keypair2);
// Create recipients file
let recipients_path = create_temp_file("recipients.txt");
let pub_key1 = unsafe { std::ffi::CStr::from_ptr(keypair1.public_key).to_str().unwrap() };
let pub_key2 = unsafe { std::ffi::CStr::from_ptr(keypair2.public_key).to_str().unwrap() };
let content = format!("# Comment line\n{}\n{}\n", pub_key1, pub_key2);
fs::write(&recipients_path, content).unwrap();
let recipients_path_c = CString::new(recipients_path.as_str()).unwrap();
// Encrypt to file
let plaintext = b"Multi-recipient from file test";
let encrypted_path = create_temp_file("multi_recip.age");
let encrypted_path_c = CString::new(encrypted_path.as_str()).unwrap();
let result = age_encrypt_to_file(
plaintext.as_ptr() as *const i8,
plaintext.len(),
encrypted_path_c.as_ptr(),
recipients_path_c.as_ptr(),
);
assert_eq!(result, AgeResult::Success);
// Both recipients should be able to decrypt
let mut output1 = AgeBuffer::null();
let result = age_decrypt_file_with_identity(
encrypted_path_c.as_ptr(),
keypair1.private_key,
&mut output1,
);
assert_eq!(result, AgeResult::Success);
let mut output2 = AgeBuffer::null();
let result = age_decrypt_file_with_identity(
encrypted_path_c.as_ptr(),
keypair2.private_key,
&mut output2,
);
assert_eq!(result, AgeResult::Success);
// Clean up
fs::remove_file(&recipients_path).ok();
fs::remove_file(&encrypted_path).ok();
age_free_buffer(&mut output1);
age_free_buffer(&mut output2);
age_free_keypair(&mut keypair1);
age_free_keypair(&mut keypair2);
}
#[test]
fn test_encrypt_to_file_empty_recipients_file() {
let plaintext = b"test";
let encrypted_path = create_temp_file("empty_recip.age");
let encrypted_path_c = CString::new(encrypted_path.as_str()).unwrap();
// Create empty recipients file
let recipients_path = create_temp_file("empty_recipients.txt");
fs::write(&recipients_path, "# Only comments\n\n").unwrap();
let recipients_path_c = CString::new(recipients_path.as_str()).unwrap();
let result = age_encrypt_to_file(
plaintext.as_ptr() as *const i8,
plaintext.len(),
encrypted_path_c.as_ptr(),
recipients_path_c.as_ptr(),
);
assert_eq!(result, AgeResult::InvalidRecipient);
// Clean up
fs::remove_file(&recipients_path).ok();
}
#[test]
fn test_encrypt_to_file_nonexistent_recipients_file() {
let plaintext = b"test";
let encrypted_path = create_temp_file("test.age");
let encrypted_path_c = CString::new(encrypted_path.as_str()).unwrap();
let recipients_path = CString::new("/nonexistent/recipients.txt").unwrap();
let result = age_encrypt_to_file(
plaintext.as_ptr() as *const i8,
plaintext.len(),
encrypted_path_c.as_ptr(),
recipients_path.as_ptr(),
);
assert_eq!(result, AgeResult::IoError);
}
#[test]
fn test_decrypt_file_corrupted_file() {
let mut keypair = AgeKeypair::null();
age_generate_x25519(&mut keypair);
// Create corrupted encrypted file
let encrypted_path = create_temp_file("corrupted.age");
fs::write(&encrypted_path, "not valid age encrypted content").unwrap();
let encrypted_path_c = CString::new(encrypted_path.as_str()).unwrap();
let mut output = AgeBuffer::null();
let result = age_decrypt_file_with_identity(
encrypted_path_c.as_ptr(),
keypair.private_key,
&mut output,
);
assert_eq!(result, AgeResult::DecryptionFailed);
// Clean up
fs::remove_file(&encrypted_path).ok();
age_free_keypair(&mut keypair);
}

View File

@@ -1,27 +0,0 @@
//! Internal helper functions for FFI conversions.
use crate::types::AgeResult;
use std::ffi::{CStr, CString};
use std::os::raw::c_char;
/// Safely convert a C string pointer to a Rust &str
pub unsafe fn cstr_to_str<'a>(ptr: *const c_char) -> Result<&'a str, AgeResult> {
if ptr.is_null() {
return Err(AgeResult::InvalidInput);
}
CStr::from_ptr(ptr)
.to_str()
.map_err(|_| AgeResult::InvalidUtf8)
}
/// Safely convert a C string pointer to a Rust String
pub unsafe fn cstr_to_string(ptr: *const c_char) -> Result<String, AgeResult> {
cstr_to_str(ptr).map(|s| s.to_owned())
}
/// Convert a Rust String to a C string pointer (caller must free)
pub fn string_to_cstr(s: String) -> Result<*mut c_char, AgeResult> {
CString::new(s)
.map(|cs| cs.into_raw())
.map_err(|_| AgeResult::InvalidInput)
}

View File

@@ -1,92 +0,0 @@
//! Key generation and derivation functions.
use crate::helpers::{cstr_to_str, string_to_cstr};
use crate::types::{AgeKeypair, AgeResult};
use age::secrecy::ExposeSecret;
use std::ffi::CString;
use std::os::raw::c_char;
use std::str::FromStr;
/// Generate a new age x25519 keypair.
///
/// # Arguments
/// * `keypair` - Pointer to receive the generated keypair
///
/// # Returns
/// AgeResult indicating success or failure
#[no_mangle]
pub extern "C" fn age_generate_x25519(keypair: *mut AgeKeypair) -> AgeResult {
if keypair.is_null() {
return AgeResult::InvalidInput;
}
let identity = age::x25519::Identity::generate();
let public_key = identity.to_public().to_string();
let private_key = identity.to_string().expose_secret().to_string();
let c_public = match string_to_cstr(public_key) {
Ok(s) => s,
Err(e) => return e,
};
let c_private = match string_to_cstr(private_key) {
Ok(s) => s,
Err(e) => {
unsafe { drop(CString::from_raw(c_public)); }
return e;
}
};
unsafe {
(*keypair).public_key = c_public;
(*keypair).private_key = c_private;
}
AgeResult::Success
}
/// Alias for age_generate_x25519 for backwards compatibility.
#[no_mangle]
pub extern "C" fn age_generate_keypair(keypair: *mut AgeKeypair) -> AgeResult {
age_generate_x25519(keypair)
}
/// Derive the public key from a private x25519 identity.
///
/// # Arguments
/// * `private_key` - The private key string (AGE-SECRET-KEY-1...)
/// * `public_key` - Pointer to receive the public key string
///
/// # Returns
/// AgeResult indicating success or failure
#[no_mangle]
pub extern "C" fn age_x25519_to_public(
private_key: *const c_char,
public_key: *mut *mut c_char,
) -> AgeResult {
if public_key.is_null() {
return AgeResult::InvalidInput;
}
let private_str = match unsafe { cstr_to_str(private_key) } {
Ok(s) => s,
Err(e) => return e,
};
let identity = match age::x25519::Identity::from_str(private_str) {
Ok(i) => i,
Err(_) => return AgeResult::InvalidIdentity,
};
let public_str = identity.to_public().to_string();
let c_public = match string_to_cstr(public_str) {
Ok(s) => s,
Err(e) => return e,
};
unsafe {
*public_key = c_public;
}
AgeResult::Success
}

View File

@@ -1,122 +0,0 @@
//! Tests for key generation and derivation functions.
use crate::keys::*;
use crate::memory::*;
use crate::types::*;
use std::ffi::CStr;
#[test]
fn test_generate_x25519_keypair() {
let mut keypair = AgeKeypair::null();
let result = age_generate_x25519(&mut keypair);
assert_eq!(result, AgeResult::Success);
assert!(!keypair.public_key.is_null());
assert!(!keypair.private_key.is_null());
unsafe {
let public = CStr::from_ptr(keypair.public_key).to_str().unwrap();
let private = CStr::from_ptr(keypair.private_key).to_str().unwrap();
assert!(public.starts_with("age1"), "Public key should start with 'age1'");
assert!(private.starts_with("AGE-SECRET-KEY-1"), "Private key should start with 'AGE-SECRET-KEY-1'");
// Check key lengths are reasonable
assert!(public.len() > 50, "Public key should be at least 50 chars");
assert!(private.len() > 50, "Private key should be at least 50 chars");
}
age_free_keypair(&mut keypair);
}
#[test]
fn test_generate_keypair_alias() {
let mut keypair = AgeKeypair::null();
let result = age_generate_keypair(&mut keypair);
assert_eq!(result, AgeResult::Success);
assert!(!keypair.public_key.is_null());
assert!(!keypair.private_key.is_null());
age_free_keypair(&mut keypair);
}
#[test]
fn test_generate_x25519_null_pointer() {
let result = age_generate_x25519(std::ptr::null_mut());
assert_eq!(result, AgeResult::InvalidInput);
}
#[test]
fn test_derive_public_key() {
let mut keypair = AgeKeypair::null();
age_generate_x25519(&mut keypair);
let mut derived_public: *mut std::os::raw::c_char = std::ptr::null_mut();
let result = age_x25519_to_public(keypair.private_key, &mut derived_public);
assert_eq!(result, AgeResult::Success);
assert!(!derived_public.is_null());
// The derived public key should match the original
let original = unsafe { CStr::from_ptr(keypair.public_key).to_str().unwrap() };
let derived = unsafe { CStr::from_ptr(derived_public).to_str().unwrap() };
assert_eq!(original, derived);
age_free_string(derived_public);
age_free_keypair(&mut keypair);
}
#[test]
fn test_derive_public_key_invalid_input() {
use std::ffi::CString;
let mut derived_public: *mut std::os::raw::c_char = std::ptr::null_mut();
// Null output pointer
let result = age_x25519_to_public(std::ptr::null(), std::ptr::null_mut());
assert_eq!(result, AgeResult::InvalidInput);
// Invalid private key
let invalid_key = CString::new("not-a-valid-key").unwrap();
let result = age_x25519_to_public(invalid_key.as_ptr(), &mut derived_public);
assert_eq!(result, AgeResult::InvalidIdentity);
}
#[test]
fn test_derive_public_key_null_private_key() {
let mut derived_public: *mut std::os::raw::c_char = std::ptr::null_mut();
// Null private key but valid output pointer
let result = age_x25519_to_public(std::ptr::null(), &mut derived_public);
assert_eq!(result, AgeResult::InvalidInput);
}
#[test]
fn test_multiple_keypair_generation() {
// Generate multiple keypairs and ensure they're all unique
let mut keypairs: Vec<AgeKeypair> = Vec::new();
for _ in 0..10 {
let mut keypair = AgeKeypair::null();
let result = age_generate_x25519(&mut keypair);
assert_eq!(result, AgeResult::Success);
keypairs.push(keypair);
}
// Check all public keys are unique
let public_keys: Vec<String> = keypairs.iter().map(|kp| {
unsafe { CStr::from_ptr(kp.public_key).to_str().unwrap().to_string() }
}).collect();
for i in 0..public_keys.len() {
for j in (i+1)..public_keys.len() {
assert_ne!(public_keys[i], public_keys[j], "Keypairs should be unique");
}
}
// Cleanup
for keypair in &mut keypairs {
age_free_keypair(keypair);
}
}

Some files were not shown because too many files have changed in this diff Show More