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
60 changed files with 2669 additions and 4465 deletions

View File

@@ -2,6 +2,8 @@ on:
push: push:
branches: branches:
- main - main
- dev
- odin
permissions: permissions:
contents: write contents: write
@@ -23,4 +25,3 @@ jobs:
# 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: simple release-type: simple
target-branch: ${{ github.ref_name }}

5
.gitignore vendored
View File

@@ -7,13 +7,8 @@ list.json
man man
# build artifacts # build artifacts
*.spall
builds builds
envr envr
envr-go envr-go
envr-prof
findr/findr
findr/findr-prof
findr/bench-*.md
result result
version.odin version.odin

View File

@@ -1,27 +1,5 @@
# Changelog # Changelog
## [0.3.0](https://github.com/sbrow/envr/compare/v0.2.1...v0.3.0) (2026-06-16)
Version 0.3.0 represents a significant departure (and improvement) for envr.
The entire codebase was rewritten in [Odin](https://odin-lang.org/) (from Go).
This reduced the binary size from over 17MB to under 600k, improved performance,
and significantly reduced the number of project dependencies from 69 to just 2.
### ⚠ BREAKING CHANGES
* The encryption format of databases has changed. Age encryption is no longer supported, and no automatic migration path was implemented.
### Features
* All encryption/decryption now happens in-memory. ([fe2b256](https://github.com/sbrow/envr/commit/fe2b256bd61eaf551d53faf3893b473a64a94667))
* Config can be loaded from any path with `--config-file (-c)` flag. ([4a26ee8](https://github.com/sbrow/envr/commit/4a26ee814591e6aab0eb99d2359d51b31011edfe))
* Switched from age to libsodium. ([23b8c2d](https://github.com/sbrow/envr/commit/23b8c2dc671a23cf76cf6746b33806ded9381486))
### Performance Improvements
* Improved writer performance. ([365e914](https://github.com/sbrow/envr/commit/365e9149b1a738ac9119bb5f74dc7e047ecfed5b))
## [0.2.1](https://github.com/sbrow/envr/compare/v0.2.0...v0.2.1) (2026-01-12) ## [0.2.1](https://github.com/sbrow/envr/compare/v0.2.0...v0.2.1) (2026-01-12)

View File

@@ -10,7 +10,7 @@ LINUX_AMD64_BIN := $(BUILD_DIR)/$(APP_NAME)-$(VERSION)-linux-amd64
LINUX_ARM64_BIN := $(BUILD_DIR)/$(APP_NAME)-$(VERSION)-linux-arm64 LINUX_ARM64_BIN := $(BUILD_DIR)/$(APP_NAME)-$(VERSION)-linux-arm64
DARWIN_ARM64_BIN := $(BUILD_DIR)/$(APP_NAME)-$(VERSION)-darwin-arm64 DARWIN_ARM64_BIN := $(BUILD_DIR)/$(APP_NAME)-$(VERSION)-darwin-arm64
.PHONY: all clean cleanall build-linux build-darwin compress release profile help .PHONY: all clean cleanall build-linux build-darwin compress release help
# Default target # Default target
all: release clean all: release clean
@@ -66,12 +66,6 @@ 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"
# Build with spall profiling instrumentation
profile:
@echo "Building with spall profiling..."
odin build . -define:SPALL=true -o:speed -out:envr-prof
@echo "Built envr-prof (run it to generate envr.spall)"
# Clean binary files only # Clean binary files only
clean: clean:
@echo "Cleaning binary files..." @echo "Cleaning binary files..."
@@ -90,7 +84,6 @@ help:
@echo " build-linux - Build Linux binaries only" @echo " build-linux - Build Linux binaries only"
@echo " build-darwin - Build Darwin binaries only" @echo " build-darwin - Build Darwin binaries only"
@echo " compress - Compress all built binaries" @echo " compress - Compress all built binaries"
@echo " profile - Build with spall profiling instrumentation"
@echo " clean - Remove binary files only" @echo " clean - Remove binary files only"
@echo " cleanall - Remove entire build directory" @echo " cleanall - Remove entire build directory"
@echo " help - Show this help message" @echo " help - Show this help message"

View File

@@ -12,13 +12,14 @@ 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
[libsodium](https://github.com/jedisct1/libsodium). [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
home directory. home directory.
- **Rename Detection**: Automatically find and updates renamed/moved - **Interactive CLI**: User-friendly prompts for file selection and management.
- 🗂️ **Rename Detection**: Automatically finds and updates renamed/moved
repositories. repositories.
## TODOS ## TODOS
@@ -27,10 +28,14 @@ repositories.
- [x] Allow configuration of ssh key. - [x] Allow configuration of ssh key.
- [x] Allow multiple ssh keys. - [x] Allow multiple ssh keys.
## Installation ## Prerequisites
You will need an SSH key pair for encryption and decryption. You can generate one - An SSH key pair (for encryption/decryption)
with `ssh-keygen -t ed25519`. It will be saved to `~/.ssh/id_ed25519`. - The following binaries:
- [fd](https://github.com/sharkdp/fd)
- [git](https://git-scm.com)
## Installation
### With Odin ### With Odin
@@ -91,12 +96,7 @@ The configuration file is created during initialization:
], ],
"scan": { "scan": {
"matcher": "\\.env", "matcher": "\\.env",
"exclude": [ "exclude": "*.envrc",
"*\\.envrc",
"\\.local/",
"node_modules",
"vendor"
],
"include": "~" "include": "~"
} }
} }

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

View File

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

113
TODOS.md
View File

@@ -1,77 +1,106 @@
# TODOs # TODO
1. Bring back windows support / cross-compilation. Note: These todos can wait until all the subcommands have been ported.
2. Commands are still leaking. (Write tests for everything first) ## HIGH
3. procedures should be ordered by use, main at the top, then in the order they are called from main. 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.
4. Check for prealloc opportunities. i.e. `make([dynamic]string)` -> `make([dynamic]string, 5)`. 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"`).
5. Test all cmds / terminal branches. ## MEDIUM
6. Generate md and man pages again. 4. **db.odin:29-35**`make_temp_path` never calls `strings.builder_destroy`. Leaks builder buffer every call.
7. Shell completion 5. **db.odin:324-327** — Map iteration (`remote_set`) is non-deterministic. Same file can produce different JSON on each backup, causing spurious DB diffs. Sort remotes before storing.
8. Add tests for untested commands. 6. **db.odin:470-473**`string_to_cstring` allocates via `strings.clone_to_cstring` and never frees. Called dozens of times across db operations.
9. Update `read_wire_string` to use a slice. 7. **db.odin:470, 462** — Both `string_to_cstring` and `cstring_to_string` ignore allocation errors. A nil cstring gets passed to SQLite (UB).
10. Pass allocator to findr? 8. **db.odin:135, 250** — String interpolation into SQL (`VACUUM INTO '%s'`, `ATTACH DATABASE '%s'`). Currently safe because input is controlled, but fragile.
11. Smarter flag parsing? 9. **features.odin:30-41**`find_binary` uses `strings.join` instead of `filepath.join`, uses `os.stat` instead of checking executability, hardcodes `:` as PATH separator (wrong on Windows).
12. Rewrite `write_command_help` to use text/tables 10. **cmd_restore.odin:20-30 & cmd_remove.odin:19-29** — Identical path-resolution block copy-pasted. `is_abs` guard is redundant since `filepath.abs` is a no-op on absolute paths. Extract a helper.
13. Instead of using a writer to strip colors, just don't print the colors. 11. **cmd_restore.odin:44**`os.mkdir_all` error silently discarded. Subsequent write failure will be confusing.
14. Add a text filter to the multi_select. 12. **cmd_edit_config.odin:27**`$EDITOR` used as single binary name. Breaks for multi-word values like `"code -w"`. Needs `strings.fields()`.
15. init -h doesn't show --force flag. Separate into multiple structs: Global_FLags, and Init_Flags? 33. **config.odin:178**`search_paths` silently ignores `os.user_home_dir` error. If home is empty, `~` isn't expanded. Same class of bug as issue 3.
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))`.
39. Lots of memory leaks to fix.
## LOW
15. **db.odin:115**`json.unmarshal_string` error not checked. Malformed JSON silently produces empty/partial data.
16. **db.odin:352-353**`hex.encode` error ignored. `string(hex_bytes)` aliases the byte slice.
18. **config.odin:51-60**`envr_dir` recomputes home dir on every call. Could cache.
37. **cmd_sync.odin:80, cmd_list.odin:33, cmd_deps.odin:9**`make([]string, 2)` for table rows never freed. Leaks per row. Defer to memory pass.
## REFACTOR
20. **cmd_list.odin** — Non-TTY branch builds `ListEntry` structs and marshals JSON separately. Now that `render_json_rows` (issue 1) accepts an `io.Writer` and uses `json.marshal`, unify both branches to use it. Note: will change JSON keys from `"directory"/"path"` to `"Directory"/"Path"`.
21. Check for prealloc opportunities. i.e. `make([dynamic]string)` -> `make([dynamic]string, 5)`.
23. Add a text filter to the multi_select.
24. Create backup / fallback fd.
25. Add tests for untested commands.
26. Add a global --config -c flag to use an alternate config.
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.
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 ## Double-check AI output
- [ ] cli.odin - [ ] cli.odin
- [ ] cli_test.odin - [ ] cli_test.odin
- [x] colors.odin - [ ] cmd_backup.odin
- [x] cmd_backup.odin - [ ] cmd_check.odin
- [x] cmd_check.odin
- [ ] cmd_check_test.odin - [ ] cmd_check_test.odin
- [x] cmd_edit_config.odin - [ ] cmd_deps.odin
- [x] cmd_init.odin - [ ] cmd_edit_config.odin
- [x] cmd_list.odin - [ ] cmd_init.odin
- [ ] cmd_list.odin
- [ ] cmd_list_test.odin - [ ] cmd_list_test.odin
- [x] cmd_nushell_completion.odin - [ ] cmd_nushell_completion.odin
- [x] cmd_nushell_completion_test.odin - [ ] cmd_nushell_completion_test.odin
- [x] cmd_remove.odin - [ ] cmd_remove.odin
- [x] cmd_restore.odin - [ ] cmd_restore.odin
- [x] cmd_scan.odin - [ ] cmd_scan.odin
- [x] cmd_sync.odin - [ ] cmd_sync.odin
- [x] cmd_version.odin - [x] cmd_version.odin
- [x] config.odin - [ ] config.odin
- [ ] config_test.odin - [ ] config_test.odin
- [ ] crypto.odin - [ ] crypto.odin
- [ ] crypto_test.odin - [ ] crypto_test.odin
- [ ] db.odin - [ ] db.odin
- [ ] db_integration_test.odin - [ ] db_integration_test.odin
- [ ] db_test.odin - [ ] db_test.odin
- [ ] flags.odin - [ ] features.odin
- [x] main.odin - [ ] features_test.odin
- [x] prompt.odin - [ ] main.odin
- [x] scan.odin - [ ] prompt.odin
- [ ] scan.odin
- [ ] scan_test.odin - [ ] scan_test.odin
- [ ] sodium.odin - [ ] sodium.odin
- [x] sqlite/sqlite.odin - [ ] sqlite/sqlite.odin
- [ ] ssh.odin - [ ] ssh.odin
- [ ] ssh_test.odin - [ ] ssh_test.odin
- [ ] table.odin - [ ] table.odin
- [ ] table_test.odin - [ ] table_test.odin
- [ ] findr/findr_test.odin
- [ ] findr/gitignore.odin
- [ ] findr/gitignore_test.odin
- [ ] findr/glob.odin
- [ ] findr/glob_test.odin
- [ ] findr/repos.odin
- [ ] findr/test_env.odin
- [ ] findr/walker.odin

92
WINDOWS.md Normal file
View File

@@ -0,0 +1,92 @@
# Windows Compatibility Guide
This document outlines Windows compatibility issues and solutions for the envr project.
## Critical Issues
### 1. Path Handling Bug (MUST FIX)
**File:** `app/env_file.go:209`
**Issue:** Uses `path.Join` instead of `filepath.Join`, which won't work correctly on Windows due to different path separators.
**Current code:**
```go
f.Path = path.Join(newDir, path.Base(f.Path))
```
**Fixed code:**
```go
f.Path = filepath.Join(newDir, filepath.Base(f.Path))
```
## External Dependencies
The application relies on external tools that need to be installed separately on Windows:
### Required Tools
1. **fd** - Fast file finder
- Install via: `winget install sharkdp.fd` or `choco install fd`
- Alternative: `scoop install fd`
2. **git** - Version control system
- Install via: `winget install Git.Git` or download from git-scm.com
- Usually already available on most development machines
## Minor Compatibility Notes
### File Permissions
- Unix file permissions (`0755`, `0644`) are used throughout the codebase
- These are safely ignored on Windows - no changes needed
### Editor Configuration
**File:** `cmd/edit_config.go:20-24`
**Issue:** Relies on `$EDITOR` environment variable which is less common on Windows.
**Current behavior:** Fails if `$EDITOR` is not set
**Recommended improvement:** Add fallback detection for Windows editors:
```go
editor := os.Getenv("EDITOR")
if editor == "" {
if runtime.GOOS == "windows" {
editor = "notepad.exe" // or "code.exe" for VS Code
} else {
fmt.Println("Error: $EDITOR environment variable is not set")
return
}
}
```
## Installation Instructions for Windows
1. Install required dependencies:
```powershell
winget install sharkdp.fd
winget install Git.Git
```
2. Fix the path handling bug in `app/env_file.go:209`
3. Build and run as normal:
```powershell
go build
.\envr.exe init
```
## Testing on Windows
After applying the critical path fix, the core functionality should work correctly on Windows. The application has been designed with cross-platform compatibility in mind, using:
- `filepath` package for path operations (mostly)
- `os.UserHomeDir()` for home directory detection
- Standard Go file operations
## Summary
- **1 critical bug** must be fixed for Windows compatibility
- **2 external tools** need to be installed
- **1 minor enhancement** recommended for better Windows UX
- Overall architecture is Windows-compatible

311
cli.odin
View File

@@ -3,39 +3,15 @@ package main
import "core:bufio" import "core:bufio"
import "core:fmt" import "core:fmt"
import "core:io" import "core:io"
import "core:mem"
import "core:os" import "core:os"
import "core:strings" import "core:strings"
import "core:terminal"
import "core:text/table"
Command :: struct { Command :: struct {
name: string, name: string,
args: [dynamic]string, args: [dynamic]string,
flags: Flags, flags: map[string]string,
out_buf: ^bufio.Writer, bool_set: map[string]bool,
out: io.Writer,
err: io.Writer,
}
// TODO: Put help test in usage:"whatever" tag.
Flags :: struct {
help: bool `args:"short=h"`,
config_file: string `args:"name=config-file,short=c"`,
output: Output_Format `args:"short=o"`,
color: Color_Mode,
force: bool `args:"short=f"`,
}
Output_Format :: enum {
Auto,
Table,
JSON,
}
Color_Mode :: enum {
Auto,
Always,
Never,
} }
CommandInfo :: struct { CommandInfo :: struct {
@@ -51,10 +27,7 @@ COMMANDS := []CommandInfo {
"init", "init",
"envr init", "envr init",
"Set up envr", "Set up envr",
`The init command generates your initial config and saves it to "The init command generates your initial config and saves it to\n~/.envr/config in JSON format.\n\nDuring setup, you will be prompted to select one or more ssh keys with which to\nencrypt your databse. **Make 100% sure** that you have **a remote copy** of this\nkey somewhere, otherwise your data could be lost forever.",
~/.envr/config in JSON format.\n\nDuring setup, you will be prompted to select one or more ssh keys with which to
encrypt your databse. **Make 100% sure** that you have **a remote copy** of this
key somewhere, otherwise your data could be lost forever.`,
{}, {},
}, },
{"scan", "envr scan", "Find and select .env files for backup", "", {}}, {"scan", "envr scan", "Find and select .env files for backup", "", {}},
@@ -64,6 +37,13 @@ key somewhere, otherwise your data could be lost forever.`,
{"list", "envr list", "View your tracked files", "", {}}, {"list", "envr list", "View your tracked files", "", {}},
{"remove", "envr remove <path>", "Remove a .env file from your database", "", {}}, {"remove", "envr remove <path>", "Remove a .env file from your database", "", {}},
{"check", "envr check [path]", "Check if files are backed up", "", {}}, {"check", "envr check [path]", "Check if files are backed up", "", {}},
{
"deps",
"envr deps",
"Check for missing binaries",
"envr relies on external binaries for certain functionality.\n\nThe check command reports on which binaries are available and which are not.",
{},
},
{"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", "", {}},
{ {
@@ -75,131 +55,62 @@ key somewhere, otherwise your data could be lost forever.`,
}, },
} }
// Caller is responsible for calling delete_command(cmd). parse_args :: proc() -> (cmd: Command, ok: bool) {
// FIXME: Works in kinda a wonky and awkward way. args := os.args
parse_args :: proc(args: []string, out: io.Stream, err: io.Stream) -> (cmd: Command, ok: bool) {
{
cmd.out_buf = new(bufio.Writer)
bufio.writer_init(cmd.out_buf, out, allocator = context.allocator)
cmd.out = bufio.writer_to_writer(cmd.out_buf)
cmd.err = err
}
if len(args) < 2 || args[1] == "--help" || args[1] == "-h" { if len(args) < 2 || args[1] == "--help" || args[1] == "-h" {
write_usage(cmd.out) print_usage()
return cmd, false return Command{}, false
} }
cmd.name = args[1] cmd.name = args[1]
cmd.args = make([dynamic]string) cmd.args = make([dynamic]string)
cmd.flags = make(map[string]string)
cmd.bool_set = make(map[string]bool)
overflow := parse_flags(&cmd.flags, args[2:]) i := 2
for arg in overflow { for i < len(args) {
arg := args[i]
if strings.starts_with(arg, "--") {
key := arg[2:]
if i + 1 < len(args) && !strings.starts_with(args[i + 1], "-") {
cmd.flags[key] = args[i + 1]
i += 2
} else {
cmd.bool_set[key] = true
i += 1
}
} else if strings.starts_with(arg, "-") && len(arg) == 2 {
key_slice := arg[1:2]
if i + 1 < len(args) && !strings.starts_with(args[i + 1], "-") {
cmd.flags[key_slice] = args[i + 1]
i += 2
} else {
cmd.bool_set[key_slice] = true
i += 1
}
} else {
append(&cmd.args, arg) append(&cmd.args, arg)
i += 1
}
} }
if cmd.flags.output == .Auto { if has_flag(&cmd, "help") {
cmd.flags.output = terminal.is_terminal(os.stdout) ? .Table : .JSON print_command_help(cmd.name)
} return Command{}, false
if cmd.flags.color == .Auto {
cmd.flags.color = terminal.is_terminal(os.stdout) ? .Always : .Never
}
if cmd.flags.color == .Never {
cmd.out = make_ansi_strip_writer(cmd.out)
}
if cmd.flags.config_file == "" {
// FIXME: Handle err
// TODO: Is this right?
home, _ := os.user_home_dir(context.temp_allocator)
// TODO: should we copy out of the temp_allocator?
cmd.flags.config_file = default_config_path(home, context.temp_allocator)
}
if cmd.flags.help {
print_command_help(&cmd)
return cmd, false
} }
return cmd, true return cmd, true
} }
print_command_help :: proc(cmd: ^Command) { has_flag :: proc(cmd: ^Command, name: string) -> bool {
ok := write_command_help(cmd.name, cmd.out) _, ok := cmd.flags[name]
if !ok { if ok {
fmt.wprintf(cmd.err, "Unknown command: %s\n", cmd.name)
write_usage(cmd.out)
}
}
write_command_help :: proc(name: string, w: io.Writer) -> bool {
info, found := find_command(name)
if !found {
return false
}
fmt.wprintf(
w,
"%s\n\n\n" +
COLOR_HEADINGS +
"Usage:" +
ANSI_RESET +
"\n\n " +
COLOR_FLAGS +
"%s" +
ANSI_RESET +
" [flags]\n\n",
info.short,
info.usage,
flush = false,
)
if len(info.aliases) > 0 {
fmt.wprintf(
w,
"\n" +
COLOR_HEADINGS +
"Aliases:" +
ANSI_RESET +
"\n\n " +
COLOR_COMMANDS +
"%s" +
ANSI_RESET,
info.name,
flush = false,
)
for a in info.aliases {
fmt.wprintf(w, ", " + COLOR_COMMANDS + "%s" + ANSI_RESET, a, flush = false)
}
fmt.wprintf(w, "\n", flush = false)
}
if len(info.long) > 0 {
fmt.wprintf(w, "\n%s\n", info.long, flush = false)
}
fmt.wprintf(
w,
"\n" +
COLOR_HEADINGS +
"Flags:" +
ANSI_RESET +
"\n\n " +
COLOR_FLAGS +
"-h, --help" +
ANSI_RESET +
" help for %s\n " +
COLOR_FLAGS +
"-c, --config-file" +
ANSI_RESET +
` <path> config file (default "~/.envr/config.json")
`,
info.name,
flush = false,
)
return true return true
} }
_, ok2 := cmd.bool_set[name]
return ok2
}
find_command :: proc(name: string) -> (CommandInfo, bool) { find_command :: proc(name: string) -> (CommandInfo, bool) {
for c in COMMANDS { for c in COMMANDS {
@@ -215,15 +126,53 @@ find_command :: proc(name: string) -> (CommandInfo, bool) {
return CommandInfo{}, false return CommandInfo{}, false
} }
// TODO: command args should be shown in usage. write_command_help :: proc(name: string, w: io.Writer) -> bool {
info, found := find_command(name)
if !found {
return false
}
fmt.wprintf(w, "Usage: %s [flags]\n\n", info.usage, flush = false)
fmt.wprintf(w, "%s\n", info.short, flush = false)
if len(info.aliases) > 0 {
fmt.wprintf(w, "\nAliases:\n %s", info.name, flush = false)
for a in info.aliases {
fmt.wprintf(w, ", %s", a, flush = false)
}
fmt.wprintf(w, "\n", flush = false)
}
if len(info.long) > 0 {
fmt.wprintf(w, "\n%s\n", info.long, flush = false)
}
fmt.wprintf(w, "\nFlags:\n -h, --help help for %s\n", info.name, flush = false)
return true
}
print_command_help :: proc(name: string) {
bw: bufio.Writer
bufio.writer_init(&bw, io.to_writer(os.to_writer(os.stdout)), mem.DEFAULT_PAGE_SIZE)
defer bufio.writer_destroy(&bw)
w := bufio.writer_to_writer(&bw)
ok := write_command_help(name, w)
if !ok {
fmt.printf("Unknown command: %s\n", name)
print_usage()
}
bufio.writer_flush(&bw)
}
write_usage :: proc(w: io.Writer) { write_usage :: proc(w: io.Writer) {
fmt.wprintf( fmt.wprintf(
w, w,
`envr keeps your .env synced to a local, encrypted database. `envr keeps your .env synced to a local, age encrypted database.
Is a safe and easy way to gather all your .env files in one place where they can Is a safe and easy way to gather all your .env files in one place where they can
easily be backed by another tool such as restic or git. easily be backed by another tool such as restic or git.
All your data is stored in ~/.envr/data.envr All your data is stored in ~/data.age
Getting started is easy: Getting started is easy:
@@ -250,69 +199,49 @@ at before, restore your backup with:
> envr restore ~/<path to repository>/.env > envr restore ~/<path to repository>/.env
%sUsage:%s Usage:
envr [command]
%senvr%s [command]
Available Commands:
`, `,
COLOR_HEADINGS,
ANSI_RESET,
COLOR_FLAGS,
ANSI_RESET,
flush = false, flush = false,
) )
tbl: table.Table
table.init(&tbl, context.temp_allocator, context.temp_allocator)
table.padding(&tbl, 2, 0)
table.caption(&tbl, "Available Commands:")
for c in COMMANDS { for c in COMMANDS {
name := c.name name_start := len(c.name)
// TODO: Can we do better? fmt.wprintf(w, "%s", c.name, flush = false)
for a in c.aliases { for a in c.aliases {
name = strings.join([]string{name, a}, ", ", tbl.format_allocator) fmt.wprintf(w, ", %s", a, flush = false)
name_start += len(a) + 2
} }
table.row(&tbl, table.format(&tbl, "%s%s%s", COLOR_COMMANDS, name, ANSI_RESET), c.short) padding := 20 - name_start
if padding > 0 {
for _ in 0 ..< padding {
io.write_byte(w, ' ')
}
}
fmt.wprintf(w, " %s\n", c.short, flush = false)
} }
write_borderless_table(w, &tbl)
table_reset(&tbl)
table.caption(&tbl, "Flags:")
table.row(&tbl, COLOR_FLAGS + "-h, --help" + ANSI_RESET, `show this documentation`)
table.row(
&tbl,
COLOR_FLAGS + "-c, --config-file" + ANSI_RESET + " <path>",
`config file (default "~/.envr/config.json")`,
)
table.row(
&tbl,
COLOR_FLAGS + "-o, --output" + ANSI_RESET + " 'table'|'json'",
`the format of output data. (default 'table')`,
)
table.row(
&tbl,
COLOR_FLAGS + "--color" + ANSI_RESET + " 'auto'|'always'|'never'",
`Whether or not to colorize output. (default 'auto')`,
)
write_borderless_table(w, &tbl)
fmt.wprintf( fmt.wprintf(
w, w,
`Use "%senvr%s [command] --help" for more information about a command.`, `
COLOR_FLAGS, Flags:
ANSI_RESET, -h, --help help for envr
Use "envr [command] --help" for more information about a command.
`,
flush = false, flush = false,
) )
} }
delete_command :: proc(cmd: ^Command) { // TODO: Look at usages,might want to pass a writer
bufio.writer_flush(cmd.out_buf) print_usage :: proc() {
delete(cmd.args) bw: bufio.Writer
bufio.writer_destroy(cmd.out_buf) bufio.writer_init(&bw, io.to_writer(os.to_writer(os.stdout)), mem.DEFAULT_PAGE_SIZE)
free(cmd.out_buf) defer bufio.writer_destroy(&bw)
defer bufio.writer_flush(&bw)
write_usage(bufio.writer_to_writer(&bw))
} }

View File

@@ -1,9 +1,7 @@
#+feature dynamic-literals #+feature dynamic-literals
package main package main
package main
import "core:fmt" import "core:fmt"
import "core:fmt"
import "core:strings" import "core:strings"
import "core:testing" import "core:testing"
@@ -58,7 +56,7 @@ test_usage_text_contains_flags_and_help_hint :: proc(t: ^testing.T) {
testing.expect(t, strings.contains(text, "Flags:"), "missing Flags section") testing.expect(t, strings.contains(text, "Flags:"), "missing Flags section")
testing.expect(t, strings.contains(text, "--help"), "missing --help flag") testing.expect(t, strings.contains(text, "--help"), "missing --help flag")
testing.expect(t, strings.contains(text, "Use \"envr [command] --help\""), "missing help hint") testing.expect(t, strings.contains(text, "Use \"envr [command] --help\""), "missing help hint")
} }
@(test) @(test)
test_command_help_backup :: proc(t: ^testing.T) { test_command_help_backup :: proc(t: ^testing.T) {
@@ -123,7 +121,7 @@ test_command_help_unknown :: proc(t: ^testing.T) {
text := strings.to_string(b) text := strings.to_string(b)
testing.expect(t, len(text) == 0, "text should be empty for unknown command") testing.expect(t, len(text) == 0, "text should be empty for unknown command")
} }
@(test) @(test)
test_command_help_version :: proc(t: ^testing.T) { test_command_help_version :: proc(t: ^testing.T) {
@@ -144,200 +142,50 @@ test_command_help_version :: proc(t: ^testing.T) {
} }
@(test) @(test)
args: []string, test_has_flag_bool_set :: proc(t: ^testing.T) {
) -> ( cmd := Command {
cmd: Command, name = "test",
ok: bool, bool_set = map[string]bool{"force" = true},
out_text: string, }
err_text: string,
) {
out_b: strings.Builder
strings.builder_init(&out_b)
defer strings.builder_destroy(&out_b)
err_b: strings.Builder
strings.builder_init(&err_b)
defer strings.builder_destroy(&err_b)
cmd, ok = parse_args(args, strings.to_stream(&out_b), strings.to_stream(&err_b))
if ok {
bufio.writer_flush(cmd.out_buf)
out_text = strings.to_string(out_b)
err_text = strings.to_string(err_b)
}
defer delete(cmd.bool_set) defer delete(cmd.bool_set)
testing.expect(t, has_flag(&cmd, "force"), "should find flag in bool_set") testing.expect(t, has_flag(&cmd, "force"), "should find flag in bool_set")
} testing.expect(t, !has_flag(&cmd, "verbose"), "should not find missing flag")
}
@(test) @(test)
test_has_flag_value_map :: proc(t: ^testing.T) { test_has_flag_value_map :: proc(t: ^testing.T) {
cmd, ok, _, _ := test_parse_args([]string{"envr", "list"}) cmd := Command {
testing.expect(t, ok, "should succeed") name = "test",
if !ok do return flags = map[string]string{"output" = "/tmp/out"},
defer delete_command(&cmd) }
defer delete(cmd.flags)
testing.expect(t, has_flag(&cmd, "output"), "should find flag in flags map") testing.expect(t, has_flag(&cmd, "output"), "should find flag in flags map")
testing.expect_value(t, len(cmd.args), 0) testing.expect(t, !has_flag(&cmd, "force"), "should not find missing flag")
} }
@(test) @(test)
test_has_flag_both_maps :: proc(t: ^testing.T) { test_has_flag_both_maps :: proc(t: ^testing.T) {
cmd, ok, _, _ := test_parse_args([]string{"envr", "backup", "/project/.env"}) cmd := Command {
defer delete_command(&cmd) name = "test",
testing.expect(t, ok, "should succeed") flags = map[string]string{"output" = "/tmp/out"},
bool_set = map[string]bool{"force" = true},
}
defer delete(cmd.flags)
defer delete(cmd.bool_set)
testing.expect(t, has_flag(&cmd, "output"), "should find in flags") testing.expect(t, has_flag(&cmd, "output"), "should find in flags")
testing.expect_value(t, len(cmd.args), 1) testing.expect(t, has_flag(&cmd, "force"), "should find in bool_set")
testing.expect_value(t, cmd.args[0], "/project/.env") testing.expect(t, !has_flag(&cmd, "verbose"), "should not find missing flag")
} }
@(test) @(test)
test_has_flag_empty_command :: proc(t: ^testing.T) { test_has_flag_empty_command :: proc(t: ^testing.T) {
cmd, ok, _, _ := test_parse_args( cmd := Command {
[]string{"envr", "sync", "--config-file", "x.json"}, name = "test",
) }
testing.expect(t, ok, "should succeed") testing.expect(t, !has_flag(&cmd, "anything"), "empty command should have no flags")
if !ok do return }
defer delete_command(&cmd)
testing.expect_value(t, cmd.flags.config_file, "x.json")
}
test_parse_args_config_file_short_flag :: proc(t: ^testing.T) {
cmd, ok, _, _ := test_parse_args([]string{"envr", "sync", "-c", "x.json"})
testing.expect(t, ok, "should succeed")
if !ok do return
defer delete_command(&cmd)
testing.expect_value(t, cmd.flags.config_file, "x.json")
}
@(test)
test_parse_args_force_long_flag :: proc(t: ^testing.T) {
cmd, ok, _, _ := test_parse_args([]string{"envr", "init", "--force"})
testing.expect(t, ok, "should succeed")
if !ok do return
defer delete_command(&cmd)
testing.expect_value(t, cmd.flags.force, true)
}
@(test)
test_parse_args_force_short_flag :: proc(t: ^testing.T) {
cmd, ok, _, _ := test_parse_args([]string{"envr", "init", "-f"})
testing.expect(t, ok, "should succeed")
if !ok do return
defer delete_command(&cmd)
testing.expect_value(t, cmd.flags.force, true)
}
@(test)
test_parse_args_multiple_positionals :: proc(t: ^testing.T) {
cmd, ok, _, _ := test_parse_args([]string{"envr", "backup", "a", "b"})
testing.expect(t, ok, "should succeed")
if !ok do return
defer delete_command(&cmd)
testing.expect_value(t, len(cmd.args), 2)
testing.expect_value(t, cmd.args[0], "a")
testing.expect_value(t, cmd.args[1], "b")
}
@(test)
test_parse_args_mixed_flags_and_positionals :: proc(t: ^testing.T) {
cmd, ok, _, _ := test_parse_args([]string{"envr", "backup", "/project/.env", "--force"})
testing.expect(t, ok, "should succeed")
if !ok do return
defer delete_command(&cmd)
testing.expect_value(t, cmd.flags.force, true)
testing.expect_value(t, len(cmd.args), 1)
testing.expect_value(t, cmd.args[0], "/project/.env")
}
@(test)
test_parse_args_no_args :: proc(t: ^testing.T) {
cmd, ok, _, _ := test_parse_args([]string{"envr"})
defer delete_command(&cmd)
testing.expect(t, !ok, "no args should return false")
}
@(test)
test_parse_args_flag_then_positional_then_flag :: proc(t: ^testing.T) {
cmd, ok, _, _ := test_parse_args([]string{"envr", "backup", "--force", "a.env", "--output", "json"})
defer delete_command(&cmd)
testing.expect(t, ok, "should succeed")
testing.expect_value(t, cmd.flags.force, true)
testing.expect_value(t, cmd.flags.output, Output_Format.JSON)
testing.expect_value(t, len(cmd.args), 1)
testing.expect_value(t, cmd.args[0], "a.env")
}
@(test)
test_parse_args_config_file_default :: proc(t: ^testing.T) {
cmd, ok, _, _ := test_parse_args([]string{"envr", "list"})
testing.expect(t, ok, "should succeed")
if !ok do return
defer delete_command(&cmd)
testing.expect(t, len(cmd.flags.config_file) > 0, "config_file should default to non-empty path")
testing.expect(
t,
strings.contains(cmd.flags.config_file, ".envr"),
"default config_file should contain .envr dir, got %s",
)
}
@(test)
test_parse_args_output_long_json :: proc(t: ^testing.T) {
cmd, ok, _, _ := test_parse_args([]string{"envr", "list", "--output", "json"})
testing.expect(t, ok, "should succeed")
if !ok do return
defer delete_command(&cmd)
testing.expect_value(t, cmd.flags.output, Output_Format.JSON)
}
@(test)
test_parse_args_output_short_json :: proc(t: ^testing.T) {
cmd, ok, _, _ := test_parse_args([]string{"envr", "list", "-o", "json"})
testing.expect(t, ok, "should succeed")
if !ok do return
defer delete_command(&cmd)
testing.expect_value(t, cmd.flags.output, Output_Format.JSON)
}
@(test)
test_parse_args_output_long_table :: proc(t: ^testing.T) {
cmd, ok, _, _ := test_parse_args([]string{"envr", "list", "--output", "table"})
testing.expect(t, ok, "should succeed")
if !ok do return
defer delete_command(&cmd)
testing.expect_value(t, cmd.flags.output, Output_Format.Table)
}
@(test)
test_parse_args_output_short_table :: proc(t: ^testing.T) {
cmd, ok, _, _ := test_parse_args([]string{"envr", "list", "-o", "table"})
testing.expect(t, ok, "should succeed")
if !ok do return
defer delete_command(&cmd)
testing.expect_value(t, cmd.flags.output, Output_Format.Table)
}
@(test)
test_parse_args_output_equals_syntax :: proc(t: ^testing.T) {
cmd, ok, _, _ := test_parse_args([]string{"envr", "list", "--output=json"})
testing.expect(t, ok, "should succeed")
if !ok do return
defer delete_command(&cmd)
testing.expect_value(t, cmd.flags.output, Output_Format.JSON)
}

View File

@@ -5,34 +5,34 @@ import "core:strings"
cmd_backup :: proc(cmd: ^Command) { cmd_backup :: proc(cmd: ^Command) {
if len(cmd.args) != 1 { if len(cmd.args) != 1 {
print_command_help(cmd) print_command_help("backup")
return return
} }
path := cmd.args[0] path := cmd.args[0]
if len(strings.trim_space(path)) == 0 { if len(strings.trim_space(path)) == 0 {
fmt.wprintln(cmd.err, "Error: No path provided", flush = false) fmt.println("Error: No path provided")
return return
} }
// TODO: allow new_env_file to accept allocator?
// TODO: Write a test that covers this leak
file, ok := new_env_file(path) file, ok := new_env_file(path)
defer delete_envfile(&file)
if !ok { if !ok {
// TODO: log a message
return return
} }
db, db_ok := db_open(cmd.flags.config_file) 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.wprintf(cmd.out, "Saved %s into the database\n", path, flush = false) fmt.printf("Saved %s into the database\n", path)
} }

View File

@@ -4,46 +4,59 @@ import "core:fmt"
import "core:os" import "core:os"
import "core:path/filepath" import "core:path/filepath"
// TODO: What happens if you pass a non existent path to cmd_check?
// TODO: UX could be improved, so "run envr add ." if file not exists.
cmd_check :: proc(cmd: ^Command) { cmd_check :: proc(cmd: ^Command) {
_check_path: string feats := check_features()
check_path: string
if len(cmd.args) > 0 { if len(cmd.args) > 0 {
_check_path = cmd.args[0] check_path = cmd.args[0]
} else { } else {
cwd, cwd_err := os.get_working_directory(context.temp_allocator) cwd, cwd_err := os.get_working_directory(context.allocator)
if cwd_err != nil { if cwd_err != nil {
fmt.wprintf(cmd.err, "Error getting current directory: %v\n", cwd_err, flush = false) fmt.printf("Error getting current directory: %v\n", cwd_err)
return return
} }
_check_path = cwd check_path = cwd
}
check_path, abs_err := filepath.abs(_check_path, context.temp_allocator)
if abs_err != nil {
fmt.wprintf(cmd.err, "Error getting absolute path: %v\n", abs_err, flush = false)
return
} }
db, db_ok := db_open(cmd.flags.config_file) abs_path: string
if filepath.is_abs(check_path) {
abs_path = check_path
} else {
resolved, abs_err := filepath.abs(check_path)
if abs_err != nil {
fmt.printf("Error getting absolute path: %v\n", abs_err)
return
}
abs_path = resolved
}
db, db_ok := db_open()
if !db_ok { if !db_ok {
return return
} }
defer db_close(&db) defer db_close(&db)
is_dir := os.is_directory(check_path) is_dir := os.is_directory(abs_path)
// TODO: set a reasonable default files_in_path: [dynamic]string
files_in_path := make([dynamic]string, context.temp_allocator)
if is_dir { if is_dir {
scanned, scan_ok := scan_path(check_path, db.cfg) if cant_scan(feats) {
fmt.println(
"Error: please install fd to use the check command (https://github.com/sharkdp/fd)",
)
return
}
scanned, scan_ok := scan_path(abs_path, db.cfg)
if !scan_ok { if !scan_ok {
fmt.wprintln(cmd.err, "Error scanning directory for .env files", flush = false) fmt.println("Error scanning directory for .env files")
return return
} }
files_in_path = scanned files_in_path = scanned
} else { } else {
append(&files_in_path, check_path) append(&files_in_path, abs_path)
} }
db_files, list_ok := db_list(&db) db_files, list_ok := db_list(&db)
@@ -55,25 +68,16 @@ cmd_check :: proc(cmd: ^Command) {
if len(not_backed) == 0 { if len(not_backed) == 0 {
if len(files_in_path) == 0 { if len(files_in_path) == 0 {
fmt.wprintln(cmd.out, "No .env files found in the specified directory.", flush = false) fmt.println("No .env files found in the specified directory.")
} else { } else {
fmt.wprintln( fmt.println("✓ All .env files in the directory are backed up.")
cmd.out,
"✓ All .env files in the directory are backed up.",
flush = false,
)
} }
} else { } else {
fmt.wprintf( fmt.printf("Found %d .env file(s) that are not backed up:\n", len(not_backed))
cmd.out,
"Found %d .env file(s) that are not backed up:\n",
len(not_backed),
flush = false,
)
for file in not_backed { for file in not_backed {
fmt.wprintf(cmd.out, " %s\n", file, flush = false) fmt.printf(" %s\n", file)
} }
fmt.wprintln(cmd.out, "\nRun 'envr sync' to back up these files.", flush = false) fmt.println("\nRun 'envr sync' to back up these files.")
} }
} }

View File

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

35
cmd_deps.odin Normal file
View File

@@ -0,0 +1,35 @@
package main
import "core:fmt"
import "core:io"
import "core:os"
import "core:terminal"
cmd_deps :: proc(cmd: ^Command) {
feats := check_features()
headers := []string{"Feature", "Status"}
rows: [dynamic][]string
if .Git in feats {
append(&rows, []string{"Git", "\u2713 Available"})
} else {
append(&rows, []string{"Git", "\u2717 Missing"})
}
if .Fd in feats {
append(&rows, []string{"fd", "\u2713 Available"})
} else {
append(&rows, []string{"fd", "\u2717 Missing"})
}
if terminal.is_terminal(os.stdout) {
w := io.to_writer(os.to_writer(os.stdout))
render_table(w, headers, rows[:])
} else {
w := io.to_writer(os.to_writer(os.stdout))
render_json_rows(w, headers, rows[:])
io.write_string(w, "\n")
}
}

View File

@@ -2,23 +2,24 @@ package main
import "core:fmt" import "core:fmt"
import "core:os" import "core:os"
import "core:path/filepath"
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)
if len(editor) == 0 { if len(editor) == 0 {
fmt.wprintln(cmd.err, "Error: $EDITOR environment variable is not set", flush = false) fmt.println("Error: $EDITOR environment variable is not set")
return return
} }
config_path := cmd.flags.config_file config_path, join_err := filepath.join([]string{envr_dir(), "config.json"})
if join_err != nil {
fmt.printf("Error building config path: %v\n", join_err)
return
}
if !os.exists(config_path) { _, stat_err := os.stat(config_path, context.allocator)
fmt.wprintf( if stat_err != nil {
cmd.err, fmt.printf("Config file does not exist at %s. Run 'envr init' first.\n", config_path)
"Config file does not exist at %s. Run 'envr init' first.\n",
config_path,
flush = false,
)
return return
} }
@@ -32,17 +33,15 @@ cmd_edit_config :: proc(cmd: ^Command) {
p, start_err := os.process_start(desc) p, start_err := os.process_start(desc)
if start_err != nil { if start_err != nil {
fmt.wprintf(cmd.err, "Error running editor: %v\n", start_err, flush = false) fmt.printf("Error running editor: %v\n", start_err)
return return
} }
state, wait_err := os.process_wait(p) state, wait_err := os.process_wait(p)
if wait_err != nil { if wait_err != nil {
fmt.wprintf(cmd.err, "Error waiting for editor: %v\n", wait_err, flush = false) fmt.printf("Error waiting for editor: %v\n", wait_err)
return return
} }
// TODO: Should we call exit inside of commands?
if state.exit_code != 0 { if state.exit_code != 0 {
os.exit(int(state.exit_code)) os.exit(int(state.exit_code))
} }

View File

@@ -1,22 +1,14 @@
package main package main
import "core:fmt" import "core:fmt"
import "core:terminal/ansi"
cmd_init :: proc(cmd: ^Command) { cmd_init :: proc(cmd: ^Command) {
force := cmd.flags.force force := has_flag(cmd, "force") || has_flag(cmd, "f")
config_file := cmd.flags.config_file
fmt.wprintln(cmd.out, cmd.flags.config_file, flush = false) _, cfg_exists := load_config()
_, cfg_exists := load_config(config_file)
if cfg_exists && !force { if cfg_exists && !force {
fmt.wprintln( fmt.println("You have already initialized envr.")
cmd.out, fmt.println("Run again with the --force flag if you want to reinitialize.")
`You have already initialized envr.
Run again with the --force flag if you want to reinitialize.`,
flush = false,
)
return return
} }
@@ -26,23 +18,14 @@ Run again with the --force flag if you want to reinitialize.`,
} }
if len(keys) == 0 { if len(keys) == 0 {
fmt.wprintln( fmt.println("No ssh-ed25519 keys found in ~/.ssh")
cmd.err, fmt.println("Generate one with: ssh-keygen -t ed25519")
`No ssh-ed25519 keys found in ~/.ssh
Generate one with: ssh-keygen -t ed25519`,
flush = false,
)
return return
} }
selected, result := multi_select("Select SSH private keys:", keys[:]) selected, result := multi_select("Select SSH private keys:", keys[:])
defer delete(selected)
if result == .Cancel { if result == .Cancel {
fmt.wprintln( fmt.println("\x1b[2mCancelled.\x1b[0m")
cmd.out,
ansi.CSI + ansi.FAINT + ansi.SGR + "Cancelled." + ANSI_RESET,
flush = false,
)
return return
} }
@@ -54,20 +37,18 @@ Generate one with: ssh-keygen -t ed25519`,
} }
if len(selected_paths) == 0 { if len(selected_paths) == 0 {
fmt.wprintln(cmd.err, "No SSH keys selected - Config not created", flush = false) fmt.println("No SSH keys selected - Config not created")
return return
} }
cfg := new_config(selected_paths[:], config_file) cfg := new_config(selected_paths[:])
if !save_config(cfg, force = force) { if !save_config(cfg, force = force) {
return return
} }
fmt.wprintf( fmt.printf(
cmd.out,
"Config initialized with %d SSH key(s). You are ready to use envr.\n", "Config initialized with %d SSH key(s). You are ready to use envr.\n",
len(selected_paths), len(selected_paths),
flush = false,
) )
} }

View File

@@ -2,19 +2,19 @@ package main
import "core:encoding/json" import "core:encoding/json"
import "core:fmt" import "core:fmt"
import "core:io"
import "core:os" import "core:os"
import "core:path/filepath" import "core:path/filepath"
import "core:strings" import "core:strings"
import "core:text/table" import "core:terminal"
ListEntry :: struct { ListEntry :: struct {
dir: string `json:"directory"`, Directory: string `json:"directory"`,
path: string `json:"path"`, Path: string `json:"path"`,
} }
// TODO: Improve table rendering
cmd_list :: proc(cmd: ^Command) { cmd_list :: proc(cmd: ^Command) {
db, db_ok := db_open(cmd.flags.config_file) db, db_ok := db_open()
if !db_ok { if !db_ok {
return return
} }
@@ -24,53 +24,42 @@ cmd_list :: proc(cmd: ^Command) {
if !list_ok { if !list_ok {
return return
} }
defer delete(rows)
if cmd.flags.output == .Table { if terminal.is_terminal(os.stdout) {
t: table.Table headers := []string{"Directory", "Path"}
table.init(&t, context.temp_allocator, context.temp_allocator) table_rows := make([dynamic][]string, 0, len(rows), context.temp_allocator)
table.padding(&t, 1, 1)
table.aligned_header_of_values(
&t,
.Center,
COLOR_TABLE_HEADING + "Directory" + ANSI_RESET,
COLOR_TABLE_HEADING + "Path" + ANSI_RESET,
)
for row in rows { for row in rows {
dir_str := strings.concatenate( dir_str := strings.concatenate({row.Dir, "/"}, context.temp_allocator)
{row.dir, os.Path_Separator_String}, filename := filepath.base(row.Path)
context.temp_allocator, row_slice := make([]string, 2)
) row_slice[0] = dir_str
filename := filepath.base(row.path) row_slice[1] = filename
append(&table_rows, row_slice)
table.row(&t, dir_str, filename)
} }
table.write_decorated_table(cmd.out, &t, decorations, ansi_aware_width) w := io.to_writer(os.to_writer(os.stdout))
render_table(w, headers, table_rows[:])
} else { } else {
// TODO: Should we instead print full entries here? entries: [dynamic]ListEntry
entries := make([dynamic]ListEntry, 0, len(rows), context.temp_allocator)
for row in rows { for row in rows {
filename := filepath.base(row.path) filename := filepath.base(row.Path)
append( append(
&entries, &entries,
ListEntry { ListEntry {
dir = strings.concatenate( Directory = strings.concatenate({row.Dir, "/"}, context.temp_allocator),
{row.dir, os.Path_Separator_String}, Path = filename,
context.temp_allocator,
),
path = filename,
}, },
) )
} }
data, marshal_err := json.marshal(entries[:])
data, marshal_err := json.marshal(entries[:], allocator = context.temp_allocator)
if marshal_err != nil { if marshal_err != nil {
fmt.wprintf(cmd.err, "Error marshaling JSON: %v\n", marshal_err, flush = false) fmt.printf("Error marshaling JSON: %v\n", marshal_err)
return return
} }
fmt.wprintln(cmd.out, string(data), flush = false) fmt.println(string(data))
} }
} }

View File

@@ -1,11 +1,6 @@
#+feature dynamic-literals
#+test
package main package main
import "core:bufio"
import "core:os"
import "core:path/filepath" import "core:path/filepath"
import "core:strings"
import "core:testing" import "core:testing"
@(test) @(test)
@@ -15,101 +10,9 @@ test_filepath_base_equals_rel :: proc(t: ^testing.T) {
for path in cases { for path in cases {
dir := filepath.dir(path) dir := filepath.dir(path)
rel, rel_err := filepath.rel(dir, path, context.temp_allocator) rel, rel_err := filepath.rel(dir, path, context.temp_allocator)
testing.expect_value(t, rel_err, nil) testing.expect(t, rel_err == nil, "filepath.rel returned an error")
base := filepath.base(path) base := filepath.base(path)
testing.expect_value(t, rel, base) testing.expect(t, rel == base, "filepath.rel(dir, path) should equal filepath.base(path)")
} }
} }
@(test)
test_cmd_list_output_json :: proc(t: ^testing.T) {
base := test_temp_dir(t, "envr-test-list-json-*")
defer os.remove_all(base)
cfg_path, _ := filepath.join([]string{base, "config.json"}, context.temp_allocator)
cfg := new_config([]string{"fixtures/keys/insecure-test-key"}, cfg_path)
testing.expect(t, save_config(cfg, force = true), "save should succeed")
delete_config(&cfg)
db, db_ok := db_open(cfg_path)
testing.expect(t, db_ok, "db should open")
if !db_ok do return
f := make_test_env_file("/project/.env", "abc123", "SECRET=value")
defer delete(f.remotes)
testing.expect(t, db_insert(&db, f), "insert should succeed")
db_close(&db)
out_b: strings.Builder
strings.builder_init(&out_b)
defer strings.builder_destroy(&out_b)
err_b: strings.Builder
strings.builder_init(&err_b)
defer strings.builder_destroy(&err_b)
cmd, ok := parse_args(
[]string{"envr", "list", "--output", "json", "--config-file", cfg_path},
strings.to_stream(&out_b),
strings.to_stream(&err_b),
)
testing.expect(t, ok, "parse_args should succeed")
if !ok do return
defer delete_command(&cmd)
cmd_list(&cmd)
bufio.writer_flush(cmd.out_buf)
output := strings.to_string(out_b)
testing.expect(t, strings.contains(output, "["), "json output should contain '['")
testing.expect(
t,
strings.contains(output, "\"directory\""),
"json output should contain directory key",
)
}
@(test)
test_cmd_list_output_table :: proc(t: ^testing.T) {
base := test_temp_dir(t, "envr-test-list-table-*")
defer os.remove_all(base)
cfg_path, _ := filepath.join([]string{base, "config.json"}, context.temp_allocator)
cfg := new_config([]string{"fixtures/keys/insecure-test-key"}, cfg_path)
testing.expect(t, save_config(cfg, force = true), "save should succeed")
delete_config(&cfg)
db, db_ok := db_open(cfg_path)
testing.expect(t, db_ok, "db should open")
if !db_ok do return
f := make_test_env_file("/project/.env", "abc123", "SECRET=value")
defer delete(f.remotes)
testing.expect(t, db_insert(&db, f), "insert should succeed")
db_close(&db)
out_b: strings.Builder
strings.builder_init(&out_b)
defer strings.builder_destroy(&out_b)
err_b: strings.Builder
strings.builder_init(&err_b)
defer strings.builder_destroy(&err_b)
cmd, ok := parse_args(
[]string{"envr", "list", "--output", "table", "--config-file", cfg_path},
strings.to_stream(&out_b),
strings.to_stream(&err_b),
)
testing.expect(t, ok, "parse_args should succeed")
if !ok do return
defer delete_command(&cmd)
cmd_list(&cmd)
bufio.writer_flush(cmd.out_buf)
output := strings.to_string(out_b)
testing.expect(t, strings.contains(output, "│"), "table output should contain border chars")
testing.expect(
t,
strings.contains(output, "Directory"),
"table output should contain Directory header",
)
}

View File

@@ -5,6 +5,5 @@ import "core:fmt"
COMPLETION_SCRIPT: string : string(#load("mod.nu")) COMPLETION_SCRIPT: string : string(#load("mod.nu"))
cmd_nushell_completion :: proc(cmd: ^Command) { cmd_nushell_completion :: proc(cmd: ^Command) {
fmt.wprint(cmd.out, COMPLETION_SCRIPT, flush = false) fmt.print(COMPLETION_SCRIPT)
} }

View File

@@ -1,4 +1,3 @@
#+test
package main package main
import "core:fmt" import "core:fmt"

View File

@@ -6,23 +6,29 @@ import "core:strings"
cmd_remove :: proc(cmd: ^Command) { cmd_remove :: proc(cmd: ^Command) {
if len(cmd.args) != 1 { if len(cmd.args) != 1 {
print_command_help(cmd) print_command_help("remove")
return return
} }
path := cmd.args[0] path := cmd.args[0]
if len(strings.trim_space(path)) == 0 { if len(strings.trim_space(path)) == 0 {
fmt.wprintln(cmd.err, "Error: No path provided", flush = false) fmt.println("Error: No path provided")
return return
} }
abs_path, abs_err := filepath.abs(path, context.temp_allocator) abs_path: string
if filepath.is_abs(path) {
abs_path = path
} else {
resolved, abs_err := filepath.abs(path)
if abs_err != nil { if abs_err != nil {
fmt.wprintf(cmd.err, "Error getting absolute path: %v\n", abs_err, flush = false) fmt.printf("Error getting absolute path: %v\n", abs_err)
return return
} }
abs_path = resolved
}
db, db_ok := db_open(cmd.flags.config_file) db, db_ok := db_open()
if !db_ok { if !db_ok {
return return
} }
@@ -32,6 +38,5 @@ cmd_remove :: proc(cmd: ^Command) {
return return
} }
fmt.wprintf(cmd.out, "Removed %s from the database\n", abs_path, flush = false) fmt.printf("Removed %s from the database\n", abs_path)
} }

View File

@@ -7,23 +7,29 @@ import "core:strings"
cmd_restore :: proc(cmd: ^Command) { cmd_restore :: proc(cmd: ^Command) {
if len(cmd.args) != 1 { if len(cmd.args) != 1 {
print_command_help(cmd) print_command_help("restore")
return return
} }
path := cmd.args[0] path := cmd.args[0]
if len(strings.trim_space(path)) == 0 { if len(strings.trim_space(path)) == 0 {
fmt.wprintln(cmd.err, "Error: No path provided", flush = false) fmt.println("Error: No path provided")
return return
} }
abs_path, abs_err := filepath.abs(path, context.temp_allocator)
abs_path: string
if filepath.is_abs(path) {
abs_path = path
} else {
resolved, abs_err := filepath.abs(path)
if abs_err != nil { if abs_err != nil {
fmt.wprintf(cmd.err, "Error getting absolute path: %v\n", abs_err, flush = false) fmt.printf("Error getting absolute path: %v\n", abs_err)
return return
} }
abs_path = resolved
}
db, db_ok := db_open(cmd.flags.config_file) db, db_ok := db_open()
if !db_ok { if !db_ok {
return return
} }
@@ -34,20 +40,14 @@ cmd_restore :: proc(cmd: ^Command) {
return return
} }
dir := filepath.dir(file.path) dir := filepath.dir(file.Path)
if err := os.mkdir_all(dir); err != nil { os.mkdir_all(dir)
fmt.wprintf(cmd.err, "Failed to create directory: %v\n", err, flush = false)
return write_err := os.write_entire_file(file.Path, file.contents)
}
write_err := os.write_entire_file(file.path, file.contents)
if write_err != nil { if write_err != nil {
fmt.wprintf(cmd.err, "Error writing file: %v\n", write_err, flush = false) fmt.printf("Error writing file: %v\n", write_err)
return return
} }
fmt.wprintf(cmd.out, "Restored %s\n", file.path, flush = false) fmt.printf("Restored %s\n", file.Path)
} }

View File

@@ -4,37 +4,34 @@ import "core:encoding/json"
import "core:fmt" import "core:fmt"
import "core:os" import "core:os"
import "core:terminal" import "core:terminal"
import "core:terminal/ansi"
cmd_scan :: proc(cmd: ^Command) { cmd_scan :: proc(cmd: ^Command) {
db, db_ok := db_open(cmd.flags.config_file) feats := check_features()
if cant_scan(feats) {
fmt.println(
"Error: please install fd to use the scan command (https://github.com/sharkdp/fd)",
)
return
}
db, db_ok := db_open()
if !db_ok { if !db_ok {
return return
} }
defer db_close(&db) defer db_close(&db)
search_dirs := search_paths(db.cfg, context.temp_allocator) search_dirs := search_paths(db.cfg)
if len(search_dirs) == 0 { if len(search_dirs) == 0 {
fmt.wprintln( fmt.println("No search paths configured. Please run `envr init` or edit your config.")
cmd.err,
"No search paths configured. Please run `envr init -f` or edit your config.",
flush = false,
)
return return
} }
// TODO: Figure out a sane default // TODO: Figure out a sane default
// Can't use temp allocator becuase strings inside are copied to context.allocator all_files: [dynamic]string
all_files := make([dynamic]string)
defer {
for &f in all_files {delete(f)}
delete(all_files)
}
for dir in search_dirs { for dir in search_dirs {
found, scan_ok := scan_path(dir, db.cfg) found, scan_ok := scan_path(dir, db.cfg)
defer delete(found)
if !scan_ok { if !scan_ok {
fmt.wprintf(cmd.err, "Error scanning %s\n", dir, flush = false) fmt.printf("Error scanning %s\n", dir)
continue continue
} }
for f in found { for f in found {
@@ -50,33 +47,23 @@ cmd_scan :: proc(cmd: ^Command) {
files := find_unbacked(all_files[:], db_files[:]) files := find_unbacked(all_files[:], db_files[:])
if len(files) == 0 { if len(files) == 0 {
fmt.wprintln(cmd.out, "No .env files found to add.", flush = false) fmt.println("No .env files found to add.")
return return
} }
if !terminal.is_terminal(os.stdout) { 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.wprintf( fmt.printf("Error marshaling files to JSON: %v\n", marshal_err)
cmd.err,
"Error marshaling files to JSON: %v\n",
marshal_err,
flush = false,
)
return return
} }
fmt.wprintln(cmd.out, string(output), flush = false) fmt.println(string(output))
return return
} }
selected, result := multi_select("Select .env files to backup:", files[:]) selected, result := multi_select("Select .env files to backup:", files[:])
defer delete(selected)
if result == .Cancel { if result == .Cancel {
fmt.wprintln( fmt.println("\x1b[2mCancelled.\x1b[0m")
cmd.out,
ansi.CSI + ansi.FAINT + ansi.SGR + "Cancelled." + ANSI_RESET,
flush = false,
)
return return
} }
@@ -85,40 +72,22 @@ cmd_scan :: proc(cmd: ^Command) {
if !selected[i] { if !selected[i] {
continue continue
} }
// TODO: Test cover this leak
env_file, ok := new_env_file(files[i]) env_file, ok := new_env_file(files[i])
defer delete_envfile(&env_file)
if !ok { if !ok {
fmt.wprintf(cmd.err, "Error reading %s\n", files[i], flush = false) fmt.printf("Error reading %s\n", files[i])
continue continue
} }
if !db_insert(&db, env_file) { if !db_insert(&db, env_file) {
fmt.wprintf(cmd.err, "Error adding %s\n", files[i], flush = false) fmt.printf("Error adding %s\n", files[i])
continue continue
} }
added_count += 1 added_count += 1
} }
if added_count > 0 { if added_count > 0 {
fmt.wprintf( fmt.printf("\x1b[1;32mSuccessfully added %d file(s) to backup.\x1b[0m\n", added_count)
cmd.out,
ansi.CSI +
ansi.BOLD +
";" +
ansi.FG_GREEN +
ansi.SGR +
"Successfully added %d file(s) to backup." +
ANSI_RESET +
"\n",
added_count,
flush = false,
)
} else { } else {
fmt.wprintln( fmt.println("\x1b[2mNo files were added.\x1b[0m")
cmd.out,
ansi.CSI + ansi.FAINT + ansi.SGR + "No files were added." + ANSI_RESET,
flush = false,
)
} }
} }

View File

@@ -2,16 +2,19 @@ package main
import "core:encoding/json" import "core:encoding/json"
import "core:fmt" import "core:fmt"
import "core:text/table" import "core:io"
import "core:os"
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. // TODO: Check for quiet failures.
cmd_sync :: proc(cmd: ^Command) { cmd_sync :: proc(cmd: ^Command) {
db, db_ok := db_open(cmd.flags.config_file) db, db_ok := db_open()
if !db_ok { if !db_ok {
return return
} }
@@ -21,75 +24,72 @@ cmd_sync :: proc(cmd: ^Command) {
if !list_ok { if !list_ok {
return return
} }
defer delete(files)
results := make([]SyncEntry, len(files), context.temp_allocator) results: [dynamic]SyncEntry
for &file, i in files { for &file in files {
result, err := db_sync(&db, &file) old_path: string
old_path, _ = strings.clone(file.Path)
result, err_msg := db_sync(&db, &file)
status: string status: string
if err != .None { is_dir_updated := .DirUpdated in result
status = sync_error_message(err)
} else if .BackedUp in result { switch {
status = .DirUpdated in result ? "Moved & Backed Up" : "Backed Up" case .Error in result:
} else if .Restored in result { if len(err_msg) > 0 {
status = .DirUpdated in result ? "Moved & Restored" : "Restored" status = err_msg
} else if .DirUpdated in result {
status = "Moved"
} else { } else {
status = "error"
}
case .BackedUp in result:
status = "Backed Up"
case .Restored in result:
status = "Restored"
case .DirUpdated in result:
status = "Moved"
case:
status = "OK" status = "OK"
} }
results[i] = SyncEntry { if is_dir_updated {
path = file.path, if !db_delete(&db, old_path) {
status = status,
}
}
if cmd.flags.output == .Table {
t: table.Table
table.init(&t, context.temp_allocator, context.temp_allocator)
table.padding(&t, 1, 1)
table.aligned_header_of_values(
&t,
.Center,
COLOR_TABLE_HEADING + "File" + ANSI_RESET,
COLOR_TABLE_HEADING + "Status" + ANSI_RESET,
)
for res in results {
table.row(&t, res.path, res.status)
}
table.write_decorated_table(cmd.out, &t, decorations, ansi_aware_width)
} else {
data, marshal_err := json.marshal(results[:], allocator = context.temp_allocator)
if marshal_err != nil {
fmt.wprintf(cmd.err, "Error marshaling JSON: %v\n", marshal_err, flush = false)
return return
} }
fmt.wprintln(cmd.out, string(data), flush = false) }
if db_update_required(result) {
if !db_insert(&db, file) {
return
} }
} }
sync_error_message :: proc(e: SyncError) -> string { path_str, _ := strings.clone(file.Path)
switch e { status_str, _ := strings.clone(status)
case .None: append(&results, SyncEntry{Path = path_str, Status = status_str})
return "" }
case .DirMissing:
return "directory missing" if terminal.is_terminal(os.stdout) {
case .MultipleDirs: headers := []string{"File", "Status"}
return "multiple directories found" table_rows := make([dynamic][]string, 0, len(results))
case .GitRootFailed:
return "failed to find git roots" for res in results {
case .WriteFailed: row_slice := make([]string, 2)
return "failed to write file" row_slice[0] = res.Path
case .ReadFailed: row_slice[1] = res.Status
return "failed to read file" append(&table_rows, row_slice)
case .DbFailed: }
return "failed to update database"
} w := io.to_writer(os.to_writer(os.stdout))
return "unknown error" render_table(w, headers, table_rows[:])
} else {
data, marshal_err := json.marshal(results[:])
if marshal_err != nil {
fmt.printf("Error marshaling JSON: %v\n", marshal_err)
return
}
fmt.println(string(data))
}
} }

View File

@@ -5,6 +5,6 @@ import "core:fmt"
VERSION :: #load("version.txt", string) VERSION :: #load("version.txt", string)
cmd_version :: proc(cmd: ^Command) { cmd_version :: proc(cmd: ^Command) {
fmt.wprintln(cmd.out, VERSION, flush = false) fmt.println(VERSION)
} }

View File

@@ -1,86 +0,0 @@
package main
import "core:io"
import "core:terminal/ansi"
COLOR_HEADINGS ::
ansi.CSI + ansi.FG_BRIGHT_GREEN + ";" + ansi.BOLD + ";" + ansi.UNDERLINE + ansi.SGR
COLOR_COMMANDS :: ansi.CSI + ansi.FG_BRIGHT_CYAN + ";" + ansi.BOLD + ansi.SGR
COLOR_EXAMPLE :: ansi.CSI + ansi.ITALIC + ansi.SGR
COLOR_FLAGS :: ansi.CSI + ansi.BOLD + ";" + ansi.FG_BRIGHT_WHITE + ansi.SGR
COLOR_TABLE_HEADING :: ansi.CSI + ansi.FG_BRIGHT_GREEN + ansi.SGR
ANSI_RESET :: ansi.CSI + ansi.RESET + ansi.SGR
ANSI_Strip_State :: enum { Normal, GotESC, InCSI }
ANSI_Strip_Data :: struct {
inner: io.Writer,
state: ANSI_Strip_State,
}
ansi_strip_proc :: proc(
stream_data: rawptr,
mode: io.Stream_Mode,
p: []byte,
offset: i64,
whence: io.Seek_From,
) -> (n: i64, err: io.Error) {
data := cast(^ANSI_Strip_Data) stream_data
#partial switch mode {
case .Write:
start := 0
for i in 0..<len(p) {
b := p[i]
switch data.state {
case .Normal:
if b == 0x1b {
if i > start {
io.write(data.inner, p[start:i])
}
data.state = .GotESC
}
case .GotESC:
if b == '[' {
data.state = .InCSI
} else {
start = i
data.state = .Normal
}
case .InCSI:
if b >= 0x40 && b <= 0x7E {
start = i + 1
data.state = .Normal
}
}
}
if data.state == .Normal && len(p) > start {
io.write(data.inner, p[start:])
}
n = i64(len(p))
return
case .Flush:
return 0, io.flush(data.inner)
case .Close:
return 0, io.close(data.inner)
case:
return data.inner.procedure(data.inner.data, mode, p, offset, whence)
}
}
make_ansi_strip_writer :: proc(inner: io.Writer) -> io.Writer {
data := new(ANSI_Strip_Data, context.temp_allocator)
data.inner = inner
return io.Writer{procedure = ansi_strip_proc, data = rawptr(data)}
}

View File

@@ -1,168 +1,88 @@
package main package main
import "base:runtime"
import "core:encoding/json" import "core:encoding/json"
import "core:fmt" import "core:fmt"
import "core:os" import "core:os"
import "core:path/filepath" import "core:path/filepath"
import "core:strings" import "core:strings"
import "findr"
Config :: struct {
keys: [dynamic]SshKeyPair `json:"keys"`,
scan_config: ScanConfig `json:"scan"`,
config_path: string `json:"-"`,
}
SshKeyPair :: struct { SshKeyPair :: struct {
private: string `json:"private"`, Private: string `json:"private"`,
public: string `json:"public"`, Public: string `json:"public"`,
} }
ScanConfig :: struct { ScanConfig :: struct {
matcher: string `json:"matcher"`, Matcher: string `json:"matcher"`,
exclude: [dynamic]string `json:"exclude"`, Exclude: [dynamic]string `json:"exclude"`,
include: [dynamic]string `json:"include"`, Include: [dynamic]string `json:"include"`,
} }
load_config :: proc(config_path: string, allocator := context.allocator) -> (Config, bool) { Config :: struct {
// TODO: Should we use context.allocator + defer delete()? Keys: [dynamic]SshKeyPair `json:"keys"`,
data, read_err := os.read_entire_file_from_path(config_path, context.temp_allocator) ScanConfig: ScanConfig `json:"scan"`,
}
load_config :: proc() -> (Config, bool) {
home, home_err := os.user_home_dir(context.temp_allocator)
if home_err != nil {
fmt.printf("Error getting home dir: %v\n", home_err)
return Config{}, false
}
config_path, join_err := filepath.join([]string{home, ".envr", "config.json"})
if join_err != nil {
return Config{}, false
}
data, read_err := os.read_entire_file_from_path(config_path, context.allocator)
if read_err != nil { if read_err != nil {
fmt.eprintln("No config file found. Please run `envr init` to generate one.") fmt.println("No config file found. Please run `envr init` to generate one.")
return Config{}, false return Config{}, false
} }
cfg: Config cfg: Config
err := json.unmarshal(data, &cfg, .JSON5, allocator) err := json.unmarshal(data, &cfg)
if err != nil { if err != nil {
fmt.eprintf("Error parsing config: %v\n", err) fmt.printf("Error parsing config: %v\n", err)
return Config{}, false return Config{}, false
} }
cfg.config_path = config_path
return cfg, true return cfg, true
} }
default_config_path :: proc(home: string, allocator := context.allocator) -> string { delete_config :: proc(cfg: Config) {
path, err := filepath.join([]string{home, ".envr", "config.json"}, allocator) delete(cfg.Keys)
if err != nil { delete(cfg.ScanConfig.Exclude)
panic("Ran out of memory when building config path") delete(cfg.ScanConfig.Include)
} }
envr_dir :: proc() -> string {
home, _ := os.user_home_dir(context.allocator)
dir, _ := filepath.join([]string{home, ".envr"})
return dir
}
data_encrypted_path :: proc() -> string {
dir := envr_dir()
path, _ := filepath.join([]string{dir, "data.envr"})
return path return path
} }
delete_config :: proc(cfg: ^Config, allocator := context.allocator) {
for key in cfg.keys {
delete(key.private, allocator)
delete(key.public, allocator)
}
delete(cfg.keys)
delete(cfg.scan_config.matcher, allocator)
for exclude in cfg.scan_config.exclude {
delete(exclude, allocator)
}
delete(cfg.scan_config.exclude)
for include in cfg.scan_config.include {
delete(include, allocator)
}
delete(cfg.scan_config.include)
}
save_config :: proc(cfg: Config, force: bool = false) -> bool {
config_dir := envr_dir(cfg.config_path)
if !os.exists(config_dir) {
mkdir_err := os.make_directory(config_dir)
if mkdir_err != nil {
fmt.eprintf("Error creating %s directory: %v\n", config_dir, mkdir_err)
return false
}
}
if os.exists(cfg.config_path) && !force {
info, stat_err := os.stat(cfg.config_path, context.temp_allocator)
if stat_err == nil {
defer os.file_info_delete(info, context.temp_allocator)
if info.size > 0 {
fmt.eprintln("Config file already exists. Run again with --force to reinitialize.")
return false
}
}
}
data, marshal_err := json.marshal(
cfg,
{pretty = true, use_spaces = true, spaces = 2},
context.temp_allocator,
)
if marshal_err != nil {
fmt.eprintf("Error marshaling config: %v\n", marshal_err)
return false
}
write_err := os.write_entire_file(cfg.config_path, data)
if write_err != nil {
fmt.eprintf("Error writing config: %v\n", write_err)
return false
}
return true
}
// Caller is responsible for calling delete_config()
new_config :: proc(
private_key_paths: []string,
cfg_path: string = "~/.envr/config.json",
) -> Config {
keys := make([dynamic]SshKeyPair, 0, len(private_key_paths))
for priv in private_key_paths {
// TODO: Is this bad?
priv_key := strings.clone(priv)
pub := strings.concatenate([]string{priv_key, ".pub"})
append(&keys, SshKeyPair{private = priv_key, public = pub})
}
// If we don't clone the strings, the cleanup semantics differ for Db created
// configs vs user created configs.
exclude := make([dynamic]string, 0, 4)
append(&exclude, strings.clone("*\\.envrc"))
append(&exclude, strings.clone("\\.local/"))
append(&exclude, strings.clone("node_modules"))
append(&exclude, strings.clone("vendor"))
include := make([dynamic]string, 0, 1)
append(&include, strings.clone("~"))
scan_cfg := ScanConfig {
matcher = strings.clone("\\.env"),
exclude = exclude,
include = include,
}
return Config{keys = keys, scan_config = scan_cfg, config_path = cfg_path}
}
find_ssh_private_keys :: proc() -> (keys: [dynamic]string, ok: bool) { find_ssh_private_keys :: proc() -> (keys: [dynamic]string, ok: bool) {
home, home_err := os.user_home_dir(context.allocator) home, home_err := os.user_home_dir(context.allocator)
if home_err != nil { if home_err != nil {
fmt.eprintf("Error getting home dir: %v\n", home_err) fmt.printf("Error getting home dir: %v\n", home_err)
return return
} }
ssh_dir, join_err := filepath.join([]string{home, ".ssh"}) ssh_dir, join_err := filepath.join([]string{home, ".ssh"})
if join_err != nil { if join_err != nil {
fmt.eprintf("Error building ssh path: %v\n", join_err) fmt.printf("Error building ssh path: %v\n", join_err)
return return
} }
entries, dir_err := os.read_all_directory_by_path(ssh_dir, context.allocator) entries, dir_err := os.read_all_directory_by_path(ssh_dir, context.allocator)
if dir_err != nil { if dir_err != nil {
fmt.eprintf("Could not read ~/.ssh directory: %v\n", dir_err) fmt.printf("Could not read ~/.ssh directory: %v\n", dir_err)
return return
} }
defer os.file_info_slice_delete(entries, context.allocator) defer os.file_info_slice_delete(entries, context.allocator)
@@ -193,55 +113,114 @@ find_ssh_private_keys :: proc() -> (keys: [dynamic]string, ok: bool) {
return return
} }
find_git_roots :: proc( new_config :: proc(private_key_paths: []string) -> Config {
cfg: Config, keys := make([dynamic]SshKeyPair, 0, len(private_key_paths))
allocator := context.temp_allocator, for priv in private_key_paths {
) -> ( // TODO: Is this bad?
roots: [dynamic]string, pub, _ := strings.concatenate([]string{priv, ".pub"}, context.temp_allocator)
ok: bool, append(&keys, SshKeyPair{Private = priv, Public = pub})
) { }
paths := search_paths(cfg, allocator)
// TODO: Pass allocator to findr exclude := make([dynamic]string, 0, 4)
findr.find_repos(paths[:], &roots, os.get_processor_core_count()) append(&exclude, "*\\.envrc")
append(&exclude, "\\.local/")
append(&exclude, "node_modules")
append(&exclude, "vendor")
include := make([dynamic]string, 0, 1)
append(&include, "~")
scan_cfg := ScanConfig {
Matcher = "\\.env",
Exclude = exclude,
Include = include,
}
return Config{Keys = keys, ScanConfig = scan_cfg}
}
save_config :: proc(cfg: Config, force: bool = false) -> bool {
home, home_err := os.user_home_dir(context.allocator)
if home_err != nil {
fmt.printf("Error getting home dir: %v\n", home_err)
return false
}
config_dir, _ := filepath.join([]string{home, ".envr"})
if !os.exists(config_dir) {
mkdir_err := os.make_directory(config_dir)
if mkdir_err != nil {
fmt.printf("Error creating ~/.envr directory: %v\n", mkdir_err)
return false
}
}
config_path, _ := filepath.join([]string{config_dir, "config.json"})
if os.exists(config_path) && !force {
info, stat_err := os.stat(config_path, context.allocator)
if stat_err == nil {
defer os.file_info_delete(info, context.allocator)
if info.size > 0 {
fmt.println("Config file already exists. Run again with --force to reinitialize.")
return false
}
}
}
data, marshal_err := json.marshal(cfg, {pretty = true, use_spaces = true, spaces = 2})
if marshal_err != nil {
fmt.printf("Error marshaling config: %v\n", marshal_err)
return false
}
write_err := os.write_entire_file(config_path, data)
if write_err != nil {
fmt.printf("Error writing config: %v\n", write_err)
return false
}
return true
}
search_paths :: proc(cfg: Config) -> (paths: [dynamic]string) {
home, _ := os.user_home_dir(context.allocator)
for include in cfg.ScanConfig.Include {
expanded, _ := strings.replace(include, "~", home, 1)
cloned, _ := strings.clone(expanded)
if filepath.is_abs(cloned) {
append(&paths, cloned)
} else {
resolved, err := filepath.abs(cloned)
if err == nil {
append(&paths, resolved)
}
}
}
return
}
find_git_roots :: proc(cfg: Config) -> (roots: [dynamic]string, ok: bool) {
paths := search_paths(cfg)
for sp in paths {
args := []string{"fd", "-H", "-t", "d", "^\\.git$", sp}
lines, fd_ok := run_fd(args)
if !fd_ok {
return
}
for line in lines {
cleaned, _ := filepath.clean(line)
parent := filepath.dir(cleaned)
cloned, _ := strings.clone(parent)
append(&roots, cloned)
}
}
ok = true ok = true
return return
} }
search_paths :: proc(cfg: Config, allocator := context.allocator) -> [dynamic]string {
home, err := os.user_home_dir(context.temp_allocator)
if err != nil {
panic("Failed to find home directory")
}
paths := new_clone(cfg.scan_config.include, allocator)
for &include in paths {
expanded, _ := strings.replace(include, "~", home, 1, allocator)
if filepath.is_abs(expanded) {
include = expanded
} else {
// TODO: show errors?
resolved, err := filepath.abs(expanded, allocator)
if err == nil {
include = resolved
}
}
}
return paths^
}
envr_dir :: proc(config_path: string) -> string {
return filepath.dir(config_path)
}
// User is responsible for freeing the path
data_path :: proc(
config_path: string,
allocator := context.allocator,
) -> (
string,
runtime.Allocator_Error,
) #optional_allocator_error {
return filepath.join([]string{envr_dir(config_path), "data.envr"}, allocator)
}

View File

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

View File

@@ -2,7 +2,6 @@ package main
import "core:fmt" import "core:fmt"
import "core:mem" import "core:mem"
import "core:os"
MAGIC :: "ENVR" MAGIC :: "ENVR"
MAGIC_BYTES := [4]u8{u8('E'), u8('N'), u8('V'), u8('R')} MAGIC_BYTES := [4]u8{u8('E'), u8('N'), u8('V'), u8('R')}
@@ -21,24 +20,85 @@ RecipientEntry :: struct {
EncryptedKey: [CRYPTO_SECRETBOX_KEY_BYTES + CRYPTO_BOX_MAC_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 { X25519Keypair :: struct {
Public: [CRYPTO_BOX_PUBLICKEY_BYTES]u8, Public: [CRYPTO_BOX_PUBLICKEY_BYTES]u8,
Private: [CRYPTO_BOX_SECRETKEY_BYTES]u8, Private: [CRYPTO_BOX_SECRETKEY_BYTES]u8,
} }
@(init) ssh_to_x25519 :: proc(keys: []SshKeyPair) -> (pairs: []X25519Keypair, ok: bool) {
init_sodium :: proc "contextless" () { if len(keys) == 0 {
if sodium_init() < 0 { return
os.exit(1) }
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
} }
} }
// TODO: Optimize performance ok = true
return
}
encrypt :: proc(plaintext: []u8, keys: []SshKeyPair) -> (ciphertext: []u8, ok: bool) { encrypt :: proc(plaintext: []u8, keys: []SshKeyPair) -> (ciphertext: []u8, ok: bool) {
x25519_pairs, pairs_ok := ssh_to_x25519(keys, context.temp_allocator) if !ensure_sodium() {
return
}
x25519_pairs, pairs_ok := ssh_to_x25519(keys)
if !pairs_ok { if !pairs_ok {
return return
} }
defer delete(x25519_pairs)
sym_key: [CRYPTO_SECRETBOX_KEY_BYTES]u8 sym_key: [CRYPTO_SECRETBOX_KEY_BYTES]u8
randombytes_buf(&sym_key[0], CRYPTO_SECRETBOX_KEY_BYTES) randombytes_buf(&sym_key[0], CRYPTO_SECRETBOX_KEY_BYTES)
@@ -47,7 +107,7 @@ encrypt :: proc(plaintext: []u8, keys: []SshKeyPair) -> (ciphertext: []u8, ok: b
randombytes_buf(&main_nonce[0], CRYPTO_SECRETBOX_NONCE_BYTES) randombytes_buf(&main_nonce[0], CRYPTO_SECRETBOX_NONCE_BYTES)
ct_len := len(plaintext) + CRYPTO_SECRETBOX_MAC_BYTES ct_len := len(plaintext) + CRYPTO_SECRETBOX_MAC_BYTES
secret_ct := make([]u8, ct_len, context.temp_allocator) secret_ct := make([]u8, ct_len)
pt_ptr: [^]u8 pt_ptr: [^]u8
if len(plaintext) > 0 { if len(plaintext) > 0 {
pt_ptr = &plaintext[0] pt_ptr = &plaintext[0]
@@ -60,13 +120,13 @@ encrypt :: proc(plaintext: []u8, keys: []SshKeyPair) -> (ciphertext: []u8, ok: b
&sym_key[0], &sym_key[0],
) )
if rc != 0 { if rc != 0 {
fmt.eprintln("Error: symmetric encryption failed") fmt.println("Error: symmetric encryption failed")
delete(secret_ct) delete(secret_ct)
return return
} }
num_recipients := u32(len(x25519_pairs)) num_recipients := u32(len(x25519_pairs))
entries := make([]RecipientEntry, num_recipients, context.temp_allocator) entries := make([]RecipientEntry, num_recipients)
for i in 0 ..< len(x25519_pairs) { for i in 0 ..< len(x25519_pairs) {
for j in 0 ..< CRYPTO_BOX_PUBLICKEY_BYTES { for j in 0 ..< CRYPTO_BOX_PUBLICKEY_BYTES {
@@ -84,7 +144,7 @@ encrypt :: proc(plaintext: []u8, keys: []SshKeyPair) -> (ciphertext: []u8, ok: b
&x25519_pairs[0].Private[0], &x25519_pairs[0].Private[0],
) )
if rc != 0 { if rc != 0 {
fmt.eprintf("Error: failed to encrypt for recipient %d\n", i) fmt.printf("Error: failed to encrypt for recipient %d\n", i)
delete(entries) delete(entries)
delete(secret_ct) delete(secret_ct)
return return
@@ -126,19 +186,25 @@ encrypt :: proc(plaintext: []u8, keys: []SshKeyPair) -> (ciphertext: []u8, ok: b
mem.copy(&ciphertext[pos], &secret_ct[0], ct_len) mem.copy(&ciphertext[pos], &secret_ct[0], ct_len)
delete(entries)
delete(secret_ct)
ok = true ok = true
return return
} }
decrypt :: proc(ciphertext: []u8, keys: []SshKeyPair) -> (plaintext: []u8, ok: bool) { decrypt :: proc(ciphertext: []u8, keys: []SshKeyPair) -> (plaintext: []u8, ok: bool) {
if !ensure_sodium() {
return
}
if len(ciphertext) < HEADER_SIZE { if len(ciphertext) < HEADER_SIZE {
fmt.eprintln("Error: ciphertext too short (header)") fmt.println("Error: ciphertext too short (header)")
return return
} }
for i in 0 ..< 4 { for i in 0 ..< 4 {
if ciphertext[i] != MAGIC_BYTES[i] { if ciphertext[i] != MAGIC_BYTES[i] {
fmt.eprintln("Error: invalid magic bytes") fmt.println("Error: invalid magic bytes")
return return
} }
} }
@@ -166,7 +232,7 @@ decrypt :: proc(ciphertext: []u8, keys: []SshKeyPair) -> (plaintext: []u8, ok: b
recipients_end := offset + int(num_recipients) * RECIPIENT_ENTRY_SIZE recipients_end := offset + int(num_recipients) * RECIPIENT_ENTRY_SIZE
if recipients_end > len(ciphertext) { if recipients_end > len(ciphertext) {
fmt.eprintln("Error: ciphertext too short (recipient data)") fmt.println("Error: ciphertext too short (recipient data)")
return return
} }
@@ -174,10 +240,11 @@ decrypt :: proc(ciphertext: []u8, keys: []SshKeyPair) -> (plaintext: []u8, ok: b
enc_nonce: [CRYPTO_BOX_NONCE_BYTES]u8 enc_nonce: [CRYPTO_BOX_NONCE_BYTES]u8
enc_pub: [CRYPTO_BOX_PUBLICKEY_BYTES]u8 enc_pub: [CRYPTO_BOX_PUBLICKEY_BYTES]u8
x25519_pairs, pairs_ok := ssh_to_x25519(keys, context.temp_allocator) x25519_pairs, pairs_ok := ssh_to_x25519(keys)
if !pairs_ok { if !pairs_ok {
return return
} }
defer delete(x25519_pairs)
found := false found := false
matched_pi := 0 matched_pi := 0
@@ -222,7 +289,7 @@ decrypt :: proc(ciphertext: []u8, keys: []SshKeyPair) -> (plaintext: []u8, ok: b
} }
if !found { if !found {
fmt.eprintln("Error: no matching recipient found") fmt.println("Error: no matching recipient found")
return return
} }
@@ -236,14 +303,14 @@ decrypt :: proc(ciphertext: []u8, keys: []SshKeyPair) -> (plaintext: []u8, ok: b
&x25519_pairs[matched_pi].Private[0], &x25519_pairs[matched_pi].Private[0],
) )
if rc != 0 { if rc != 0 {
fmt.eprintln("Error: failed to decrypt symmetric key") fmt.println("Error: failed to decrypt symmetric key")
return return
} }
ct_data := ciphertext[recipients_end:] ct_data := ciphertext[recipients_end:]
pt_len := len(ct_data) - CRYPTO_SECRETBOX_MAC_BYTES pt_len := len(ct_data) - CRYPTO_SECRETBOX_MAC_BYTES
if pt_len < 0 { if pt_len < 0 {
fmt.eprintln("Error: ciphertext too short (no encrypted data)") fmt.println("Error: ciphertext too short (no encrypted data)")
return return
} }
@@ -260,7 +327,7 @@ decrypt :: proc(ciphertext: []u8, keys: []SshKeyPair) -> (plaintext: []u8, ok: b
&sym_key[0], &sym_key[0],
) )
if rc != 0 { if rc != 0 {
fmt.eprintln("Error: symmetric decryption failed") fmt.println("Error: symmetric decryption failed")
delete(plaintext) delete(plaintext)
return return
} }
@@ -269,57 +336,3 @@ decrypt :: proc(ciphertext: []u8, keys: []SshKeyPair) -> (plaintext: []u8, ok: b
return return
} }
ssh_to_x25519 :: proc(
keys: []SshKeyPair,
allocator := context.temp_allocator,
) -> (
[]X25519Keypair,
bool,
) {
if len(keys) == 0 {
return {}, false
}
pairs := make([]X25519Keypair, len(keys), allocator)
for i in 0 ..< len(keys) {
ssh_kp, parse_ok := parse_ssh_private_key(keys[i].private)
if !parse_ok {
fmt.eprintf("Error: failed to parse SSH private key: %s\n", keys[i].private)
delete(pairs)
return pairs, false
}
ssh_pub, pub_ok := parse_ssh_public_key(keys[i].public)
if !pub_ok {
fmt.eprintf("Error: failed to parse SSH public key: %s\n", keys[i].public)
delete(pairs)
return pairs, false
}
pk_rc := crypto_sign_ed25519_pk_to_curve25519(&pairs[i].Public[0], &ssh_pub[0])
if pk_rc != 0 {
fmt.eprintln("Error: failed to convert ed25519 public key to curve25519")
delete(pairs)
return pairs, false
}
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.eprintln("Error: failed to convert ed25519 private key to curve25519")
delete(pairs)
return pairs, false
}
}
return pairs, true
}

View File

@@ -1,16 +1,14 @@
#+test
package main package main
import "core:fmt" import "core:fmt"
import "core:os"
import "core:testing" import "core:testing"
CRYPTO_TEST_KEY_DIR :: "fixtures" + os.Path_Separator_String + "keys" CRYPTO_TEST_KEY_DIR :: "fixtures/keys"
make_test_key_pair :: proc(name: string) -> SshKeyPair { make_test_key_pair :: proc(name: string) -> SshKeyPair {
priv := fmt.tprintf("%s/%s", CRYPTO_TEST_KEY_DIR, name) priv := fmt.tprintf("%s/%s", CRYPTO_TEST_KEY_DIR, name)
pub := fmt.tprintf("%s/%s.pub", CRYPTO_TEST_KEY_DIR, name) pub := fmt.tprintf("%s/%s.pub", CRYPTO_TEST_KEY_DIR, name)
return SshKeyPair{private = priv, public = pub} return SshKeyPair{Private = priv, Public = pub}
} }
@(test) @(test)
@@ -27,10 +25,9 @@ test_encrypt_decrypt_roundtrip :: proc(t: ^testing.T) {
testing.expect(t, dec_ok, "decryption should succeed") testing.expect(t, dec_ok, "decryption should succeed")
defer delete(decrypted) defer delete(decrypted)
testing.expect_value(t, len(decrypted), len(original)) testing.expect(t, len(decrypted) == len(original), fmt.tprintf("expected %d bytes, got %d", len(original), len(decrypted)))
// TODO: Should this be a loop?
for i in 0 ..< len(original) { for i in 0 ..< len(original) {
testing.expect_value(t, decrypted[i], original[i]) testing.expect(t, decrypted[i] == original[i], fmt.tprintf("byte mismatch at index %d", i))
} }
} }
@@ -53,8 +50,8 @@ test_encrypt_decrypt_multi_recipient :: proc(t: ^testing.T) {
defer delete(decrypted2) defer delete(decrypted2)
for i in 0 ..< len(original) { for i in 0 ..< len(original) {
testing.expect_value(t, decrypted1[i], original[i]) testing.expect(t, decrypted1[i] == original[i], fmt.tprintf("key1: byte mismatch at %d", i))
testing.expect_value(t, decrypted2[i], original[i]) testing.expect(t, decrypted2[i] == original[i], fmt.tprintf("key2: byte mismatch at %d", i))
} }
} }
@@ -85,27 +82,7 @@ test_encrypt_empty_plaintext :: proc(t: ^testing.T) {
testing.expect(t, dec_ok, "decryption should succeed") testing.expect(t, dec_ok, "decryption should succeed")
defer delete(decrypted) defer delete(decrypted)
testing.expect_value(t, len(decrypted), 0) testing.expect(t, len(decrypted) == 0, "decrypted empty data should be empty")
}
@(test)
test_recipient_can_decrypt_senders_data :: proc(t: ^testing.T) {
key1 := make_test_key_pair("test_ed25519")
key2 := make_test_key_pair("test_ed25519_second")
original := []u8{10, 20, 30, 40, 50}
encrypted, enc_ok := encrypt(original, []SshKeyPair{key1, key2})
testing.expect(t, enc_ok, "encryption with 2 keys should succeed")
defer delete(encrypted)
decrypted, dec_ok := decrypt(encrypted, []SshKeyPair{key2})
testing.expect(t, dec_ok, "second recipient should decrypt without the sender key present")
defer delete(decrypted)
// TODO: Should this be a loop?
for i in 0 ..< len(original) {
testing.expect_value(t, decrypted[i], original[i])
}
} }
@(test) @(test)
@@ -118,9 +95,8 @@ test_ciphertext_has_magic :: proc(t: ^testing.T) {
defer delete(encrypted) defer delete(encrypted)
testing.expect(t, len(encrypted) >= 4, "ciphertext should have at least 4 bytes") testing.expect(t, len(encrypted) >= 4, "ciphertext should have at least 4 bytes")
testing.expect_value(t, encrypted[0], u8('E')) testing.expect(t, encrypted[0] == u8('E'), "magic byte 0")
testing.expect_value(t, encrypted[1], u8('N')) testing.expect(t, encrypted[1] == u8('N'), "magic byte 1")
testing.expect_value(t, encrypted[2], u8('V')) testing.expect(t, encrypted[2] == u8('V'), "magic byte 2")
testing.expect_value(t, encrypted[3], u8('R')) testing.expect(t, encrypted[3] == u8('R'), "magic byte 3")
} }

917
db.odin

File diff suppressed because it is too large Load Diff

View File

@@ -1,4 +1,3 @@
#+test
package main package main
import "core:fmt" import "core:fmt"
@@ -11,14 +10,6 @@ import "sqlite"
FIXTURES :: "fixtures" FIXTURES :: "fixtures"
test_temp_dir :: proc(t: ^testing.T, prefix: string) -> string {
dir, err := os.mkdir_temp("", prefix, context.temp_allocator)
if err != nil {
testing.fail_now(t, fmt.tprintf("Failed to create temp dir: %v", err))
}
return dir
}
fixture_key :: proc() -> SshKeyPair { fixture_key :: proc() -> SshKeyPair {
priv, _ := strings.concatenate( priv, _ := strings.concatenate(
[]string{FIXTURES, "/keys/insecure-test-key"}, []string{FIXTURES, "/keys/insecure-test-key"},
@@ -28,7 +19,7 @@ fixture_key :: proc() -> SshKeyPair {
[]string{FIXTURES, "/keys/insecure-test-key.pub"}, []string{FIXTURES, "/keys/insecure-test-key.pub"},
context.temp_allocator, context.temp_allocator,
) )
return SshKeyPair{private = priv, public = pub} return SshKeyPair{Private = priv, Public = pub}
} }
fixture_db_path :: proc() -> string { fixture_db_path :: proc() -> string {
@@ -38,9 +29,9 @@ fixture_db_path :: proc() -> string {
fixture_config :: proc() -> Config { fixture_config :: proc() -> Config {
cfg := Config { cfg := Config {
keys = make([dynamic]SshKeyPair, 0, 1), Keys = make([dynamic]SshKeyPair, 0, 1),
} }
append(&cfg.keys, fixture_key()) append(&cfg.Keys, fixture_key())
return cfg return cfg
} }
@@ -48,7 +39,7 @@ fixture_config :: proc() -> Config {
test_encrypt_decrypt_sqlite_roundtrip :: proc(t: ^testing.T) { test_encrypt_decrypt_sqlite_roundtrip :: proc(t: ^testing.T) {
cfg := fixture_config() cfg := fixture_config()
defer { defer {
delete(cfg.keys) delete(cfg.Keys)
} }
db_path := fixture_db_path() db_path := fixture_db_path()
@@ -59,7 +50,7 @@ test_encrypt_decrypt_sqlite_roundtrip :: proc(t: ^testing.T) {
} }
defer delete(sqlite_data) defer delete(sqlite_data)
encrypted, enc_ok := encrypt(sqlite_data, cfg.keys[:]) encrypted, enc_ok := encrypt(sqlite_data, cfg.Keys[:])
testing.expect(t, enc_ok, "encryption should succeed") testing.expect(t, enc_ok, "encryption should succeed")
if !enc_ok { if !enc_ok {
return return
@@ -67,12 +58,12 @@ test_encrypt_decrypt_sqlite_roundtrip :: proc(t: ^testing.T) {
defer delete(encrypted) defer delete(encrypted)
testing.expect(t, len(encrypted) >= HEADER_SIZE, "ciphertext should have header") testing.expect(t, len(encrypted) >= HEADER_SIZE, "ciphertext should have header")
testing.expect_value(t, encrypted[0], u8('E')) testing.expect(t, encrypted[0] == u8('E'), "magic byte 0")
testing.expect_value(t, encrypted[1], u8('N')) testing.expect(t, encrypted[1] == u8('N'), "magic byte 1")
testing.expect_value(t, encrypted[2], u8('V')) testing.expect(t, encrypted[2] == u8('V'), "magic byte 2")
testing.expect_value(t, encrypted[3], u8('R')) testing.expect(t, encrypted[3] == u8('R'), "magic byte 3")
plaintext, dec_ok := decrypt(encrypted, cfg.keys[:]) plaintext, dec_ok := decrypt(encrypted, cfg.Keys[:])
testing.expect(t, dec_ok, "decryption should succeed") testing.expect(t, dec_ok, "decryption should succeed")
if !dec_ok { if !dec_ok {
return return
@@ -101,7 +92,7 @@ test_encrypt_decrypt_sqlite_roundtrip :: proc(t: ^testing.T) {
test_encrypt_write_read_decrypt :: proc(t: ^testing.T) { test_encrypt_write_read_decrypt :: proc(t: ^testing.T) {
cfg := fixture_config() cfg := fixture_config()
defer { defer {
delete(cfg.keys) delete(cfg.Keys)
} }
db_path := fixture_db_path() db_path := fixture_db_path()
@@ -112,21 +103,20 @@ test_encrypt_write_read_decrypt :: proc(t: ^testing.T) {
} }
defer delete(sqlite_data) defer delete(sqlite_data)
encrypted, enc_ok := encrypt(sqlite_data, cfg.keys[:]) encrypted, enc_ok := encrypt(sqlite_data, cfg.Keys[:])
testing.expect(t, enc_ok, "encryption should succeed") testing.expect(t, enc_ok, "encryption should succeed")
if !enc_ok { if !enc_ok {
return return
} }
defer delete(encrypted) defer delete(encrypted)
ewrd_dir := test_temp_dir(t, "envr-test-ewrd-*") tmp_enc_path := fmt.tprintf("/tmp/envr-test-ewrd-%d.envr", os.get_pid())
defer os.remove_all(ewrd_dir)
tmp_enc_path, _ := filepath.join([]string{ewrd_dir, "data.envr"}, context.temp_allocator)
write_err := os.write_entire_file(tmp_enc_path, encrypted) write_err := os.write_entire_file(tmp_enc_path, encrypted)
testing.expectf(t, write_err == nil, "failed to write encrypted file: %v", write_err) testing.expectf(t, write_err == nil, "failed to write encrypted file: %v", write_err)
if write_err != nil { if write_err != nil {
return return
} }
defer os.remove(tmp_enc_path)
read_back, rb_err := os.read_entire_file_from_path(tmp_enc_path, context.allocator) 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) testing.expectf(t, rb_err == nil, "failed to read back encrypted file: %v", rb_err)
@@ -135,21 +125,21 @@ test_encrypt_write_read_decrypt :: proc(t: ^testing.T) {
} }
defer delete(read_back) defer delete(read_back)
plaintext, dec_ok := decrypt(read_back, cfg.keys[:]) plaintext, dec_ok := decrypt(read_back, cfg.Keys[:])
testing.expect(t, dec_ok, "decryption after write/read should succeed") testing.expect(t, dec_ok, "decryption after write/read should succeed")
if !dec_ok { if !dec_ok {
return return
} }
defer delete(plaintext) defer delete(plaintext)
testing.expect_value(t, len(plaintext), len(sqlite_data)) testing.expect(t, len(plaintext) == len(sqlite_data), "size mismatch after file round-trip")
} }
@(test) @(test)
test_decrypt_then_deserialize_sqlite :: proc(t: ^testing.T) { test_decrypt_then_attach_sqlite :: proc(t: ^testing.T) {
cfg := fixture_config() cfg := fixture_config()
defer { defer {
delete(cfg.keys) delete(cfg.Keys)
} }
db_path := fixture_db_path() db_path := fixture_db_path()
@@ -160,54 +150,56 @@ test_decrypt_then_deserialize_sqlite :: proc(t: ^testing.T) {
} }
defer delete(sqlite_data) defer delete(sqlite_data)
encrypted, enc_ok := encrypt(sqlite_data, cfg.keys[:]) encrypted, enc_ok := encrypt(sqlite_data, cfg.Keys[:])
testing.expect(t, enc_ok, "encryption should succeed") testing.expect(t, enc_ok, "encryption should succeed")
if !enc_ok { if !enc_ok {
return return
} }
defer delete(encrypted) defer delete(encrypted)
plaintext, dec_ok := decrypt(encrypted, cfg.keys[:]) plaintext, dec_ok := decrypt(encrypted, cfg.Keys[:])
testing.expect(t, dec_ok, "decryption should succeed") testing.expect(t, dec_ok, "decryption should succeed")
if !dec_ok { if !dec_ok {
return return
} }
defer delete(plaintext) defer delete(plaintext)
mem_db: sqlite.Db tmp_db_path := fmt.tprintf("/tmp/envr-test-attach-%d.db", os.get_pid())
rc := sqlite.open(":memory:", &mem_db) 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") testing.expectf(t, rc == sqlite.OK, "failed to open in-memory db")
if rc != sqlite.OK { if rc != sqlite.OK {
return return
} }
defer sqlite.close(mem_db) defer sqlite.db_close(mem_db)
n := i64(len(plaintext)) 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)"
buf := sqlite.malloc64(n) rc = sqlite.db_exec(mem_db, string_to_cstring(create_sql), nil, nil, nil)
testing.expect(t, buf != nil, "malloc64 should succeed") testing.expect(t, rc == sqlite.OK, "failed to create table")
if buf == nil do return
copy(buf[:len(plaintext)], plaintext)
rc = sqlite.deserialize(mem_db, "main", buf, n, n, {.FREEONCLOSE, .RESIZEABLE}) attach_ok := db_attach_and_copy(mem_db, tmp_db_path)
testing.expect_value(t, rc, sqlite.OK) testing.expect(t, attach_ok, "failed to attach and copy")
if rc != sqlite.OK {
sqlite.free(buf)
return
}
sql: cstring = "SELECT path FROM envr_env_files" sql := "SELECT path FROM envr_env_files"
stmt: sqlite.Stmt stmt: ^rawptr
rc = sqlite.prepare_v2(mem_db, sql, -1, &stmt, nil) rc = sqlite.prepare_v2(mem_db, string_to_cstring(sql), -1, &stmt, nil)
testing.expect_value(t, rc, sqlite.OK) testing.expect(t, rc == sqlite.OK, "prepare failed")
if rc != sqlite.OK { if rc != sqlite.OK {
return return
} }
defer sqlite.finalize(stmt) defer sqlite.finalize(stmt)
rc = sqlite.step(stmt) rc = sqlite.step(stmt)
testing.expect_value(t, rc, sqlite.ROW) testing.expect(t, rc == sqlite.ROW, "expected at least one row")
if rc == sqlite.ROW { if rc == sqlite.ROW {
path := string(sqlite.column_text(stmt, 0)) path := cstring_to_string(sqlite.column_text(stmt, 0))
testing.expect(t, len(path) > 0, "path should not be empty") testing.expect(t, len(path) > 0, "path should not be empty")
} }
} }
@@ -215,7 +207,9 @@ test_decrypt_then_deserialize_sqlite :: proc(t: ^testing.T) {
@(test) @(test)
test_full_db_cycle :: proc(t: ^testing.T) { test_full_db_cycle :: proc(t: ^testing.T) {
cfg := fixture_config() cfg := fixture_config()
defer delete(cfg.keys) defer {
delete(cfg.Keys)
}
db_path := fixture_db_path() db_path := fixture_db_path()
original_data, read_err := os.read_entire_file_from_path(db_path, context.allocator) original_data, read_err := os.read_entire_file_from_path(db_path, context.allocator)
@@ -225,22 +219,17 @@ test_full_db_cycle :: proc(t: ^testing.T) {
} }
defer delete(original_data) defer delete(original_data)
encrypted, enc_ok := encrypt(original_data, cfg.keys[:]) encrypted, enc_ok := encrypt(original_data, cfg.Keys[:])
testing.expect(t, enc_ok, "first encryption should succeed") testing.expect(t, enc_ok, "first encryption should succeed")
if !enc_ok { if !enc_ok {
return return
} }
defer delete(encrypted) defer delete(encrypted)
cycle_dir := test_temp_dir(t, "envr-test-cycle-*") envr_dir_path := fmt.tprintf("/tmp/envr-test-cycle-%d/.envr", os.get_pid())
defer os.remove_all(cycle_dir) os.mkdir_all(envr_dir_path)
envr_dir_path, _ := filepath.join([]string{cycle_dir, ".envr"}, context.temp_allocator)
{
err := os.mkdir_all(envr_dir_path)
testing.expect_value(t, err, nil)
}
data_path, _ := filepath.join([]string{envr_dir_path, "data.envr"}, context.temp_allocator) data_path, _ := filepath.join([]string{envr_dir_path, "data.envr"})
write_err := os.write_entire_file(data_path, encrypted) write_err := os.write_entire_file(data_path, encrypted)
testing.expectf(t, write_err == nil, "failed to write data.envr: %v", write_err) testing.expectf(t, write_err == nil, "failed to write data.envr: %v", write_err)
if write_err != nil { if write_err != nil {
@@ -254,28 +243,36 @@ test_full_db_cycle :: proc(t: ^testing.T) {
} }
defer delete(read_back) defer delete(read_back)
plaintext, dec_ok := decrypt(read_back, cfg.keys[:]) plaintext, dec_ok := decrypt(read_back, cfg.Keys[:])
testing.expect(t, dec_ok, "decryption should succeed") testing.expect(t, dec_ok, "decryption should succeed")
if !dec_ok { if !dec_ok {
return return
} }
defer delete(plaintext) defer delete(plaintext)
encrypted2, enc2_ok := encrypt(plaintext, cfg.keys[:]) encrypted2, enc2_ok := encrypt(plaintext, cfg.Keys[:])
testing.expect(t, enc2_ok, "re-encryption should succeed") testing.expect(t, enc2_ok, "re-encryption should succeed")
if !enc2_ok { if !enc2_ok {
return return
} }
defer delete(encrypted2) defer delete(encrypted2)
plaintext2, dec2_ok := decrypt(encrypted2, cfg.keys[:]) plaintext2, dec2_ok := decrypt(encrypted2, cfg.Keys[:])
testing.expect(t, dec2_ok, "second decryption should succeed") testing.expect(t, dec2_ok, "second decryption should succeed")
if !dec2_ok { if !dec2_ok {
return return
} }
defer delete(plaintext2) defer delete(plaintext2)
testing.expect_value(t, len(plaintext2), len(original_data)) 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(data_path)
os.remove(envr_dir_path) os.remove(envr_dir_path)
@@ -287,13 +284,13 @@ test_full_db_cycle :: proc(t: ^testing.T) {
test_ssh_key_parse_from_fixtures :: proc(t: ^testing.T) { test_ssh_key_parse_from_fixtures :: proc(t: ^testing.T) {
key := fixture_key() key := fixture_key()
priv_kp, priv_ok := parse_ssh_private_key(key.private) priv_kp, priv_ok := parse_ssh_private_key(key.Private)
testing.expect(t, priv_ok, "should parse private key from fixtures") testing.expect(t, priv_ok, "should parse private key from fixtures")
if !priv_ok { if !priv_ok {
return return
} }
pub_key, pub_ok := parse_ssh_public_key(key.public) pub_key, pub_ok := parse_ssh_public_key(key.Public)
testing.expect(t, pub_ok, "should parse public key from fixtures") testing.expect(t, pub_ok, "should parse public key from fixtures")
if !pub_ok { if !pub_ok {
return return
@@ -308,28 +305,29 @@ test_ssh_key_parse_from_fixtures :: proc(t: ^testing.T) {
if !x_ok { if !x_ok {
return return
} }
defer delete(x25519_pairs)
testing.expect_value(t, len(x25519_pairs), 1) testing.expect(t, len(x25519_pairs) == 1, "should have 1 x25519 keypair")
} }
@(test) @(test)
test_config_load_with_fixture_key :: proc(t: ^testing.T) { test_config_load_with_fixture_key :: proc(t: ^testing.T) {
cfg := fixture_config() cfg := fixture_config()
defer { defer {
delete(cfg.keys) delete(cfg.Keys)
} }
testing.expect_value(t, len(cfg.keys), 1) testing.expect(t, len(cfg.Keys) == 1, "should have 1 key")
key := cfg.keys[0] key := cfg.Keys[0]
testing.expectf(t, len(key.private) > 0, "private key path should not be empty") 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") testing.expectf(t, len(key.Public) > 0, "public key path should not be empty")
_, priv_ok := parse_ssh_private_key(key.private) _, priv_ok := parse_ssh_private_key(key.Private)
testing.expect(t, priv_ok, "should parse private key using config paths") testing.expect(t, priv_ok, "should parse private key using config paths")
if !priv_ok { if !priv_ok {
fmt.printf(" private key path was: '%s'\n", key.private) fmt.printf(" private key path was: '%s'\n", key.Private)
} }
} }

View File

@@ -1,216 +1,277 @@
#+test
package main package main
import "core:crypto/hash"
import "core:encoding/hex"
import "core:fmt" import "core:fmt"
import "core:os" import "core:os"
import "core:path/filepath"
import "core:strings"
import "core:testing" import "core:testing"
import "sqlite" 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 { make_test_env_file :: proc(path, sha, contents: string, remotes: []string = {}) -> EnvFile {
f := EnvFile { f := EnvFile {
path = path, Path = path,
dir = "", Dir = "",
sha256 = sha, Sha256 = sha,
contents = contents, contents = contents,
remotes = make([dynamic]string, 0, len(remotes), context.temp_allocator), Remotes = make([dynamic]string, 0, len(remotes)),
} }
for r in remotes { for r in remotes {
append(&f.remotes, r) append(&f.Remotes, r)
} }
return f return f
} }
@(test) @(test)
test_db_insert_and_fetch :: proc(t: ^testing.T) { test_db_insert_and_fetch :: proc(t: ^testing.T) {
db, ok := db_init() d, ok := make_test_db()
testing.expect(t, ok, "failed to create test db") testing.expect(t, ok, "failed to create test db")
if !ok do return if !ok do return
defer db_close(&db) defer sqlite.db_close(d.db)
path := "/project/.env" f := make_test_env_file(
sha := "abc123" "/project/.env",
contents := "SECRET=value" "abc123",
"SECRET=value",
[]string{"git@github.com:user/repo.git"},
)
defer delete(f.Remotes)
f := make_test_env_file(path, sha, contents, []string{"git@github.com:user/repo.git"}) testing.expect(t, db_insert(&d, f), "insert should succeed")
defer delete(f.remotes)
testing.expect(t, db_insert(&db, f), "insert should succeed") fetched, fetch_ok := db_fetch(&d, "/project/.env")
fetched, fetch_ok := db_fetch(&db, "/project/.env")
// defer delete_envfile(&fetched)
testing.expect(t, fetch_ok, "fetch should succeed") testing.expect(t, fetch_ok, "fetch should succeed")
if !fetch_ok do return if !fetch_ok do return
defer delete(fetched.Remotes)
testing.expect_value(t, fetched.path, path) testing.expect(t, fetched.Path == "/project/.env", "path mismatch")
testing.expect_value(t, fetched.sha256, sha) testing.expect(t, fetched.Sha256 == "abc123", "sha mismatch")
testing.expect_value(t, fetched.contents, contents) testing.expect(t, fetched.contents == "SECRET=value", "contents mismatch")
testing.expect_value(t, len(fetched.remotes), 1) testing.expect(t, len(fetched.Remotes) == 1, "remotes count mismatch")
testing.expect_value(t, fetched.remotes[0], "git@github.com:user/repo.git") testing.expect(t, fetched.Remotes[0] == "git@github.com:user/repo.git", "remote mismatch")
} }
@(test) @(test)
test_db_fetch_missing :: proc(t: ^testing.T) { test_db_fetch_missing :: proc(t: ^testing.T) {
db, ok := db_init() d, ok := make_test_db()
testing.expect(t, ok, "failed to create test db") testing.expect(t, ok, "failed to create test db")
if !ok do return if !ok do return
defer db_close(&db) defer sqlite.db_close(d.db)
_, fetch_ok := db_fetch(&db, "/nonexistent/.env") _, fetch_ok := db_fetch(&d, "/nonexistent/.env")
testing.expect(t, !fetch_ok, "fetch missing should return false") testing.expect(t, !fetch_ok, "fetch missing should return false")
} }
@(test) @(test)
test_db_insert_or_replace :: proc(t: ^testing.T) { test_db_insert_or_replace :: proc(t: ^testing.T) {
db, ok := db_init() d, ok := make_test_db()
defer db_close(&db)
testing.expect(t, ok, "failed to create 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") f1 := make_test_env_file("/project/.env", "sha1", "KEY=old")
defer delete(f1.remotes) defer delete(f1.Remotes)
testing.expect(t, db_insert(&db, f1), "first insert should succeed") testing.expect(t, db_insert(&d, f1), "first insert should succeed")
f2 := make_test_env_file("/project/.env", "sha2", "KEY=new") f2 := make_test_env_file("/project/.env", "sha2", "KEY=new")
defer delete(f2.remotes) defer delete(f2.Remotes)
testing.expect(t, db_insert(&db, f2), "second insert should succeed") testing.expect(t, db_insert(&d, f2), "second insert should succeed")
results, list_ok := db_list(&db) results, list_ok := db_list(&d)
testing.expect(t, list_ok, "list should succeed") testing.expect(t, list_ok, "list should succeed")
if !list_ok do return
defer delete(results)
testing.expect_value(t, len(results), 1) testing.expect(t, len(results) == 1, "should have 1 row, not 2")
fetched, fetch_ok := db_fetch(&db, "/project/.env") fetched, fetch_ok := db_fetch(&d, "/project/.env")
testing.expect(t, fetch_ok, "fetch should succeed") testing.expect(t, fetch_ok, "fetch should succeed")
if !fetch_ok do return if !fetch_ok do return
// defer delete_envfile(&fetched) defer delete(fetched.Remotes)
testing.expect_value(t, fetched.contents, "KEY=new") testing.expect(t, fetched.contents == "KEY=new", "contents should be updated")
testing.expect_value(t, fetched.sha256, "sha2") testing.expect(t, fetched.Sha256 == "sha2", "sha should be updated")
} }
@(test) @(test)
test_db_delete_existing :: proc(t: ^testing.T) { test_db_delete_existing :: proc(t: ^testing.T) {
db, ok := db_init() d, ok := make_test_db()
testing.expect(t, ok, "failed to create test db") testing.expect(t, ok, "failed to create test db")
if !ok do return if !ok do return
defer db_close(&db) defer sqlite.db_close(d.db)
f := make_test_env_file("/project/.env", "sha", "KEY=val") f := make_test_env_file("/project/.env", "sha", "KEY=val")
defer delete(f.remotes) defer delete(f.Remotes)
db_insert(&db, f) db_insert(&d, f)
testing.expect(t, db_delete(&db, "/project/.env"), "delete should return true") testing.expect(t, db_delete(&d, "/project/.env"), "delete should return true")
_, fetch_ok := db_fetch(&db, "/project/.env") _, fetch_ok := db_fetch(&d, "/project/.env")
testing.expect(t, !fetch_ok, "row should be gone after delete") testing.expect(t, !fetch_ok, "row should be gone after delete")
} }
@(test) @(test)
test_db_delete_missing :: proc(t: ^testing.T) { test_db_delete_missing :: proc(t: ^testing.T) {
db, ok := db_init() d, ok := make_test_db()
testing.expect(t, ok, "failed to create test db") testing.expect(t, ok, "failed to create test db")
if !ok do return if !ok do return
defer db_close(&db) defer sqlite.db_close(d.db)
testing.expect(t, !db_delete(&db, "/nonexistent/.env"), "delete missing should return false") testing.expect(t, !db_delete(&d, "/nonexistent/.env"), "delete missing should return false")
} }
@(test) @(test)
test_db_list_multiple :: proc(t: ^testing.T) { test_db_list_multiple :: proc(t: ^testing.T) {
db, ok := db_init() d, ok := make_test_db()
testing.expect(t, ok, "failed to create test db") testing.expect(t, ok, "failed to create test db")
defer db_close(&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"}) f1 := make_test_env_file("/proj1/.env", "sha1", "A=1", []string{"git@github.com:a/repo.git"})
defer delete(f1.remotes)
f2 := make_test_env_file("/proj2/.env", "sha2", "B=2", []string{"git@github.com:b/repo.git"}) f2 := make_test_env_file("/proj2/.env", "sha2", "B=2", []string{"git@github.com:b/repo.git"})
defer delete(f2.remotes)
f3 := make_test_env_file("/proj3/.env", "sha3", "C=3") f3 := make_test_env_file("/proj3/.env", "sha3", "C=3")
defer delete(f1.Remotes)
defer delete(f2.Remotes)
defer delete(f3.Remotes)
db_insert(&db, f1) db_insert(&d, f1)
db_insert(&db, f2) db_insert(&d, f2)
db_insert(&db, f3) db_insert(&d, f3)
results, list_ok := db_list(&db) results, list_ok := db_list(&d)
testing.expect(t, list_ok, "list should succeed") testing.expect(t, list_ok, "list should succeed")
if !list_ok do return
defer delete(results)
testing.expect_value(t, len(results), 3) testing.expect(t, len(results) == 3, "should have 3 rows")
} }
@(test) @(test)
test_db_list_empty :: proc(t: ^testing.T) { test_db_list_empty :: proc(t: ^testing.T) {
db, ok := db_init() d, ok := make_test_db()
testing.expect(t, ok, "failed to create test db") testing.expect(t, ok, "failed to create test db")
defer db_close(&db) if !ok do return
defer sqlite.db_close(d.db)
results, list_ok := db_list(&db) results, list_ok := db_list(&d)
testing.expect(t, list_ok, "list should succeed on empty db") testing.expect(t, list_ok, "list should succeed on empty db")
testing.expect_value(t, len(results), 0) testing.expect(t, len(results) == 0, "should have 0 rows")
if list_ok do delete(results)
} }
@(test) @(test)
test_db_insert_sets_changed :: proc(t: ^testing.T) { test_db_insert_sets_changed :: proc(t: ^testing.T) {
db, ok := db_init() d, ok := make_test_db()
testing.expect(t, ok, "failed to create test db") testing.expect(t, ok, "failed to create test db")
if !ok do return if !ok do return
defer db_close(&db) defer sqlite.db_close(d.db)
testing.expect(t, !db.changed, "changed should start false") testing.expect(t, !d.changed, "changed should start false")
f := make_test_env_file("/project/.env", "sha", "KEY=val") f := make_test_env_file("/project/.env", "sha", "KEY=val")
defer delete(f.remotes) defer delete(f.Remotes)
db_insert(&db, f) db_insert(&d, f)
testing.expect(t, db.changed, "changed should be true after insert") testing.expect(t, d.changed, "changed should be true after insert")
} }
@(test) @(test)
test_db_delete_sets_changed :: proc(t: ^testing.T) { test_db_delete_sets_changed :: proc(t: ^testing.T) {
db, ok := db_init() d, ok := make_test_db()
testing.expect(t, ok, "failed to create test db") testing.expect(t, ok, "failed to create test db")
if !ok do return if !ok do return
defer db_close(&db) defer sqlite.db_close(d.db)
f := make_test_env_file("/project/.env", "sha", "KEY=val") f := make_test_env_file("/project/.env", "sha", "KEY=val")
defer delete(f.remotes) defer delete(f.Remotes)
db_insert(&db, f) db_insert(&d, f)
db.changed = false d.changed = false
db_delete(&db, "/project/.env") db_delete(&d, "/project/.env")
testing.expect(t, db.changed, "changed should be true after delete") testing.expect(t, d.changed, "changed should be true after delete")
} }
@(test) @(test)
test_db_serialize :: proc(t: ^testing.T) { test_db_vacuum_to_file :: proc(t: ^testing.T) {
db, ok := db_init() d, ok := make_test_db()
testing.expect(t, ok, "failed to create test db") testing.expect(t, ok, "failed to create test db")
if !ok do return if !ok do return
defer db_close(&db) defer sqlite.db_close(d.db)
f := make_test_env_file("/project/.env", "sha", "KEY=val") f := make_test_env_file("/project/.env", "sha", "KEY=val")
defer delete(f.remotes) defer delete(f.Remotes)
db_insert(&db, f) db_insert(&d, f)
sz: i64 vacuum_path := fmt.tprintf("/tmp/envr-test-vacuum-%d.db", os.get_pid())
data := sqlite.serialize(db.conn, "main", &sz, {}) defer os.remove(vacuum_path)
testing.expect(t, data != nil, "serialize should return non-nil")
if data == nil do return
defer sqlite.free(data)
testing.expect(t, sz > 0, "serialized size should be > 0") 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_db_update_required_noop :: proc(t: ^testing.T) {
testing.expect(t, !db_update_required({}), "Noop should not require update")
}
@(test)
test_db_update_required_backed_up :: proc(t: ^testing.T) {
testing.expect(t, db_update_required({.BackedUp}), "BackedUp should require update")
}
@(test)
test_db_update_required_dir_updated :: proc(t: ^testing.T) {
testing.expect(t, db_update_required({.DirUpdated}), "DirUpdated should require update")
}
@(test)
test_db_update_required_restored :: proc(t: ^testing.T) {
testing.expect(t, !db_update_required({.Restored}), "Restored alone should not require update")
}
@(test)
test_db_update_required_error :: proc(t: ^testing.T) {
testing.expect(t, !db_update_required({.Error}), "Error alone should not require update")
}
@(test)
test_db_update_required_combined :: proc(t: ^testing.T) {
combined := SyncFlag{.DirUpdated, .Restored}
testing.expect(t, db_update_required(combined), "DirUpdated|Restored should require update")
} }
@(test) @(test)
test_shares_remote_overlap :: proc(t: ^testing.T) { test_shares_remote_overlap :: proc(t: ^testing.T) {
f := EnvFile { f := EnvFile {
remotes = make([dynamic]string, 2, context.temp_allocator), Remotes = make([dynamic]string, 2, context.temp_allocator),
} }
append(&f.remotes, "git@github.com:user/repo.git") append(&f.Remotes, "git@github.com:user/repo.git")
append(&f.remotes, "git@gitlab.com:user/repo.git") append(&f.Remotes, "git@gitlab.com:user/repo.git")
remotes := []string{"git@github.com:user/repo.git"} remotes := []string{"git@github.com:user/repo.git"}
testing.expect(t, shares_remote(&f, remotes), "should share remote") testing.expect(t, shares_remote(&f, remotes), "should share remote")
@@ -219,9 +280,9 @@ test_shares_remote_overlap :: proc(t: ^testing.T) {
@(test) @(test)
test_shares_remote_no_overlap :: proc(t: ^testing.T) { test_shares_remote_no_overlap :: proc(t: ^testing.T) {
f := EnvFile { f := EnvFile {
remotes = make([dynamic]string, 1, context.temp_allocator), Remotes = make([dynamic]string, 1, context.temp_allocator),
} }
append(&f.remotes, "git@github.com:user/repo.git") append(&f.Remotes, "git@github.com:user/repo.git")
remotes := []string{"git@github.com:other/repo.git"} remotes := []string{"git@github.com:other/repo.git"}
testing.expect(t, !shares_remote(&f, remotes), "should not share remote") testing.expect(t, !shares_remote(&f, remotes), "should not share remote")
@@ -230,7 +291,7 @@ test_shares_remote_no_overlap :: proc(t: ^testing.T) {
@(test) @(test)
test_shares_remote_empty_file_remotes :: proc(t: ^testing.T) { test_shares_remote_empty_file_remotes :: proc(t: ^testing.T) {
f := EnvFile { f := EnvFile {
remotes = make([dynamic]string, 0, context.temp_allocator), Remotes = make([dynamic]string, 0, context.temp_allocator),
} }
remotes := []string{"git@github.com:user/repo.git"} remotes := []string{"git@github.com:user/repo.git"}
@@ -240,9 +301,9 @@ test_shares_remote_empty_file_remotes :: proc(t: ^testing.T) {
@(test) @(test)
test_shares_remote_empty_check_remotes :: proc(t: ^testing.T) { test_shares_remote_empty_check_remotes :: proc(t: ^testing.T) {
f := EnvFile { f := EnvFile {
remotes = make([dynamic]string, 1, context.temp_allocator), Remotes = make([dynamic]string, 1, context.temp_allocator),
} }
append(&f.remotes, "git@github.com:user/repo.git") append(&f.Remotes, "git@github.com:user/repo.git")
remotes: []string remotes: []string
testing.expect(t, !shares_remote(&f, remotes), "empty check remotes should not share") testing.expect(t, !shares_remote(&f, remotes), "empty check remotes should not share")
@@ -251,311 +312,10 @@ test_shares_remote_empty_check_remotes :: proc(t: ^testing.T) {
@(test) @(test)
test_shares_remote_both_empty :: proc(t: ^testing.T) { test_shares_remote_both_empty :: proc(t: ^testing.T) {
f := EnvFile { f := EnvFile {
remotes = make([dynamic]string, 0), Remotes = make([dynamic]string, 0),
} }
remotes: []string remotes: []string
testing.expect(t, !shares_remote(&f, remotes), "both empty should not share") testing.expect(t, !shares_remote(&f, remotes), "both empty should not share")
} }
delete_remotes :: proc(remotes: [dynamic]string) {
for &r in remotes {
delete(r)
}
delete(remotes)
}
@(test)
test_get_git_remotes_single :: proc(t: ^testing.T) {
base := test_temp_dir(t, "envr-test-remotes-*")
defer os.remove_all(base)
git_dir := fmt.tprintf("%s/.git", base)
os.mkdir_all(git_dir)
config_content := "[core]\n\trepositoryformatversion = 0\n[remote \"origin\"]\n\turl = git@github.com:user/repo.git\n\tfetch = +refs/heads/*:refs/remotes/origin/*\n"
config_path := fmt.tprintf("%s/config", git_dir)
err := os.write_entire_file(config_path, transmute([]u8)config_content)
testing.expect_value(t, err, nil)
remotes := get_git_remotes(base, context.temp_allocator)
testing.expect_value(t, len(remotes), 1)
if len(remotes) != 1 do return
testing.expect_value(t, remotes[0], "git@github.com:user/repo.git")
}
@(test)
test_get_git_remotes_multiple :: proc(t: ^testing.T) {
base := test_temp_dir(t, "envr-test-remotes-multi-*")
defer os.remove_all(base)
git_dir := fmt.tprintf("%s/.git", base)
os.mkdir_all(git_dir)
config_content := "[remote \"origin\"]\n\turl = git@github.com:user/repo.git\n[remote \"upstream\"]\n\turl = https://gitlab.com/upstream/repo.git\n"
config_path := fmt.tprintf("%s/config", git_dir)
err := os.write_entire_file(config_path, transmute([]u8)config_content)
testing.expect_value(t, err, nil)
remotes := get_git_remotes(base, context.temp_allocator)
testing.expect_value(t, len(remotes), 2)
}
@(test)
test_get_git_remotes_no_config :: proc(t: ^testing.T) {
base := test_temp_dir(t, "envr-test-remotes-none-*")
defer os.remove_all(base)
remotes := get_git_remotes(base, context.temp_allocator)
testing.expect_value(t, len(remotes), 0)
}
@(test)
test_get_git_remotes_no_remotes :: proc(t: ^testing.T) {
base := test_temp_dir(t, "envr-test-remotes-empty-*")
defer os.remove_all(base)
git_dir := fmt.tprintf("%s/.git", base)
os.mkdir_all(git_dir)
config_content := "[core]\n\trepositoryformatversion = 0\n\tbare = false\n"
config_path := fmt.tprintf("%s/config", git_dir)
err := os.write_entire_file(config_path, transmute([]u8)config_content)
testing.expect_value(t, err, nil)
remotes := get_git_remotes(base, context.temp_allocator)
testing.expect_value(t, len(remotes), 0)
}
@(test)
test_new_env_file :: proc(t: ^testing.T) {
base := test_temp_dir(t, "envr-test-envfile-*")
defer os.remove_all(base)
env_path := fmt.tprintf("%s/.env", base)
err := os.write_entire_file(env_path, "SECRET=value\n")
testing.expect_value(t, err, nil)
file, ok := new_env_file(env_path)
testing.expect(t, ok, "new_env_file should succeed")
if !ok do return
defer delete(file.contents)
defer delete(file.remotes)
defer delete(file.sha256)
defer delete(file.path)
testing.expect(t, filepath.is_abs(file.path), "path should be absolute")
testing.expect(t, strings.has_suffix(file.path, "/.env"), "path should end with /.env")
testing.expect_value(t, file.contents, "SECRET=value\n")
testing.expect_value(t, len(file.sha256), 64)
}
@(test)
test_new_env_file_missing :: proc(t: ^testing.T) {
_, ok := new_env_file("/tmp/envr-nonexistent-envfile/path/.env")
testing.expect(t, !ok, "missing file should return false")
}
@(test)
test_closing_db_has_no_leaks :: proc(t: ^testing.T) {
base := test_temp_dir(t, "envr-test-leak-*")
defer os.remove_all(base)
cfg_path, err := filepath.join([]string{base, "config.json"}, context.temp_allocator)
testing.expect_value(t, err, nil)
{
cfg := new_config([]string{"fixtures/keys/insecure-test-key"}, cfg_path)
testing.expect(t, save_config(cfg, force = true), "save should succeed")
delete_config(&cfg)
}
db, ok := db_open(cfg_path)
testing.expect(t, ok, "db should open")
db_close(&db)
}
@(test)
test_open_existing_db_has_no_leaks :: proc(t: ^testing.T) {
base := test_temp_dir(t, "envr-test-leak-existing-*")
defer os.remove_all(base)
cfg_path, err := filepath.join([]string{base, "config.json"}, context.temp_allocator)
testing.expect_value(t, err, nil)
{
cfg := new_config([]string{"fixtures/keys/insecure-test-key"}, cfg_path)
testing.expect(t, save_config(cfg, force = true), "save should succeed")
delete_config(&cfg)
}
// First open/close creates data.envr on disk
db, ok := db_open(cfg_path)
testing.expect(t, ok, "db should open")
if !ok do return
f := make_test_env_file(
"/project/.env",
"abc123",
"SECRET=value",
[]string{"git@github.com:user/repo.git"},
)
defer delete(f.remotes)
testing.expect(t, db_insert(&db, f), "insert should succeed")
db_close(&db)
// Second open exercises db_restore_from_encrypted
db2, ok2 := db_open(cfg_path)
testing.expect(t, ok2, "db should open existing")
if !ok2 do return
db_close(&db2)
}
@(test)
test_db_sync_noop :: proc(t: ^testing.T) {
base := test_temp_dir(t, "envr-test-sync-noop-*")
defer os.remove_all(base)
env_path := fmt.tprintf("%s/.env", base)
content := "KEY=value\n"
write_err := os.write_entire_file(env_path, transmute([]u8)content)
testing.expect_value(t, write_err, nil)
digest := hash.hash_bytes(
hash.Algorithm.SHA256,
transmute([]u8)content,
context.temp_allocator,
)
hex_bytes := hex.encode(digest, context.temp_allocator)
sha := string(hex_bytes)
db, ok := db_init()
testing.expect(t, ok, "failed to create test db")
defer db_close(&db)
f := make_test_env_file(env_path, sha, content)
f.dir = base
db_insert(&db, f)
result, sync_err := db_sync(&db, &f)
testing.expect_value(t, sync_err, SyncError.None)
testing.expect_value(t, result, nil)
}
@(test)
test_db_sync_backed_up :: proc(t: ^testing.T) {
base := test_temp_dir(t, "envr-test-sync-backup-*")
defer os.remove_all(base)
env_path := fmt.tprintf("%s/.env", base)
changed_content := "KEY=changed\n"
write_err := os.write_entire_file(env_path, transmute([]u8)changed_content)
testing.expect_value(t, write_err, nil)
db, ok := db_init()
testing.expect(t, ok, "failed to create test db")
defer db_close(&db)
f := make_test_env_file(env_path, "old_sha", "KEY=original")
f.dir = base
db_insert(&db, f)
result, sync_err := db_sync(&db, &f)
testing.expect_value(t, sync_err, SyncError.None)
testing.expect(t, .BackedUp in result, "should be backed up")
}
@(test)
test_db_sync_restored :: proc(t: ^testing.T) {
base := test_temp_dir(t, "envr-test-sync-restore-*")
defer os.remove_all(base)
env_path := fmt.tprintf("%s/.env", base)
db, ok := db_init()
testing.expect(t, ok, "failed to create test db")
defer db_close(&db)
f := make_test_env_file(env_path, "some_sha", "SECRET=value")
f.dir = base
defer delete(f.remotes)
db_insert(&db, f)
result, err := db_sync(&db, &f)
testing.expect_value(t, err, SyncError.None)
testing.expect(t, .Restored in result, "should be restored")
data, read_err := os.read_entire_file_from_path(env_path, context.temp_allocator)
testing.expect_value(t, read_err, nil)
if read_err == nil {
testing.expect_value(t, string(data), "SECRET=value")
}
}
@(test)
test_db_sync_dir_missing :: proc(t: ^testing.T) {
db, ok := db_init()
testing.expect(t, ok, "failed to create test db")
defer db_close(&db)
f := make_test_env_file("/nonexistent/path/.env", "sha", "KEY=val")
db_insert(&db, f)
result, err := db_sync(&db, &f)
testing.expect_value(t, err, SyncError.DirMissing)
testing.expect_value(t, result, nil)
}
@(test)
test_db_sync_moved :: proc(t: ^testing.T) {
base := test_temp_dir(t, "envr-test-sync-moved-*")
search_root := fmt.tprintf("%s/search", base)
repo_dir := fmt.tprintf("%s/myproject", search_root)
git_dir := fmt.tprintf("%s/.git", repo_dir)
defer os.remove_all(base)
os.mkdir_all(git_dir)
config_content := "[remote \"origin\"]\n\turl = git@github.com:user/repo.git\n"
config_path := fmt.tprintf("%s/config", git_dir)
write_err := os.write_entire_file(config_path, transmute([]u8)config_content)
testing.expect_value(t, write_err, nil)
db, ok := db_init()
testing.expect(t, ok, "failed to create test db")
defer db_close(&db)
db.cfg.scan_config.include = make([dynamic]string, 0, 1, context.temp_allocator)
append(&db.cfg.scan_config.include, search_root)
f := make_test_env_file(
"/old/nonexistent/path/.env",
"some_sha",
"SECRET=value",
[]string{"git@github.com:user/repo.git"},
)
testing.expect(t, db_insert(&db, f), "insert should succeed")
result, err := db_sync(&db, &f)
testing.expect_value(t, err, SyncError.None)
if err != .None do return
testing.expect(t, .DirUpdated in result, "should have DirUpdated flag")
testing.expect(t, .Restored in result, "should have Restored flag")
expected_path := fmt.tprintf("%s/.env", repo_dir)
testing.expect_value(t, f.path, expected_path)
testing.expect_value(t, f.dir, repo_dir)
_, old_exists := db_fetch(&db, "/old/nonexistent/path/.env")
testing.expect(t, !old_exists, "old path should be deleted from db")
new_fetched, new_ok := db_fetch(&db, expected_path)
testing.expect(t, new_ok, "new path should exist in db")
if new_ok {
testing.expect_value(t, new_fetched.contents, "SECRET=value")
}
}

View File

@@ -4,11 +4,11 @@ Manage your .env files.
### Synopsis ### Synopsis
envr keeps your .env synced to a local, encrypted database. 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 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. easily be backed by another tool such as restic or git.
All your data is stored in ~/.envr/data.envr All your data is stored in ~/data.age
Getting started is easy: Getting started is easy:
@@ -45,6 +45,7 @@ at before, restore your backup with:
* [envr backup](envr_backup.md) - Import a .env file into envr * [envr backup](envr_backup.md) - Import a .env file into envr
* [envr check](envr_check.md) - check if files in the current directory are backed up * [envr check](envr_check.md) - check if files in the current directory are backed up
* [envr deps](envr_deps.md) - Check for missing binaries
* [envr edit-config](envr_edit-config.md) - Edit your config with your default editor * [envr edit-config](envr_edit-config.md) - Edit your config with your default editor
* [envr init](envr_init.md) - Set up envr * [envr init](envr_init.md) - Set up envr
* [envr list](envr_list.md) - View your tracked files * [envr list](envr_list.md) - View your tracked files

24
docs/cli/envr_deps.md Normal file
View File

@@ -0,0 +1,24 @@
## envr deps
Check for missing binaries
### Synopsis
envr relies on external binaries for certain functionality.
The check command reports on which binaries are available and which are not.
```
envr deps [flags]
```
### Options
```
-h, --help help for deps
```
### SEE ALSO
* [envr](envr.md) - Manage your .env files.

51
features.odin Normal file
View File

@@ -0,0 +1,51 @@
package main
import "base:runtime"
import "core:mem"
import "core:os"
import "core:strings"
Feature :: enum {
Git,
Fd,
}
AvailableFeatures :: bit_set[Feature]
check_features :: proc() -> AvailableFeatures {
feats: AvailableFeatures
s: mem.Scratch
mem.scratch_init(&s, 4 * mem.DEFAULT_PAGE_SIZE)
defer mem.scratch_destroy(&s)
context.temp_allocator = mem.scratch_allocator(&s)
path_env := os.get_env("PATH", context.temp_allocator)
paths := strings.split(path_env, ":", context.temp_allocator)
if find_binary(paths, "git") != "" {
feats += {.Git}
}
if find_binary(paths, "fd") != "" {
feats += {.Fd}
}
return feats
}
find_binary :: proc(
paths: []string,
name: string,
allocator: runtime.Allocator = context.temp_allocator,
) -> string {
for p in paths {
candidate := strings.join({strings.trim_right(p, "/"), name}, "/", allocator)
_, err := os.stat(candidate, allocator)
if err == nil {
return candidate
}
}
return ""
}

34
features_test.odin Normal file
View File

@@ -0,0 +1,34 @@
package main
import "core:os"
import "core:strings"
import "core:testing"
@(test)
test_find_binary_exists :: proc(t: ^testing.T) {
path := os.get_env("PATH", context.temp_allocator)
paths := strings.split(path, ":", context.temp_allocator)
result := find_binary(paths, "sh")
testing.expect(t, result != "", "sh should be found on PATH")
}
@(test)
test_find_binary_not_exists :: proc(t: ^testing.T) {
old_path := os.get_env("PATH", context.temp_allocator)
defer {
if old_path != "" {
os.set_env("PATH", old_path)
}
}
os.set_env("PATH", "/tmp/envr-nope")
path := os.get_env("PATH", context.temp_allocator)
paths := strings.split(path, ":", context.temp_allocator)
result := find_binary(paths, "no_such_binary_xyz")
testing.expect(t, result == "", "nonexistent binary should not be found")
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,134 +0,0 @@
package main
import "base:runtime"
import "core:reflect"
import "core:strings"
get_subtag :: proc(tag: string, id: string) -> (value: string, ok: bool) {
parts := strings.split(tag, ",", context.temp_allocator)
for part in parts {
trimmed := strings.trim_space(part)
if strings.has_prefix(trimmed, id) && len(trimmed) > len(id) && trimmed[len(id)] == '=' {
return trimmed[len(id) + 1:], true
}
if trimmed == id {
return "", true
}
}
return "", false
}
is_bool_type :: proc(field: reflect.Struct_Field) -> bool {
base_ti := runtime.type_info_base(field.type)
_, is_bool := base_ti.variant.(runtime.Type_Info_Boolean)
return is_bool
}
set_field :: proc(model: rawptr, field: reflect.Struct_Field, value: string) -> bool {
ptr := rawptr(uintptr(model) + field.offset)
base_ti := runtime.type_info_base(field.type)
if _, is_bool := base_ti.variant.(runtime.Type_Info_Boolean); is_bool {
(cast(^bool)ptr)^ = true
return true
}
if _, is_string := base_ti.variant.(runtime.Type_Info_String); is_string {
(cast(^string)ptr)^ = value
return true
}
if enum_ti, is_enum := base_ti.variant.(runtime.Type_Info_Enum); is_enum {
for name, i in enum_ti.names {
if strings.equal_fold(value, name) {
v := enum_ti.values[i]
switch base_ti.size {
case 1: (cast(^u8)ptr)^ = cast(u8)v
case 2: (cast(^u16)ptr)^ = cast(u16)v
case 4: (cast(^u32)ptr)^ = cast(u32)v
case 8: (cast(^u64)ptr)^ = cast(u64)v
}
return true
}
}
}
return false
}
parse_flags :: proc(model: ^$T, args: []string) -> (overflow: []string) {
field_count := reflect.struct_field_count(T)
long_map := make(map[string]reflect.Struct_Field, field_count, context.temp_allocator)
short_map := make(map[string]reflect.Struct_Field, field_count, context.temp_allocator)
for i in 0..<field_count {
field := reflect.struct_field_at(T, i)
name, _ := strings.replace(field.name, "_", "-", -1, context.temp_allocator)
args_tag := reflect.struct_tag_get(field.tag, "args")
if n, ok := get_subtag(args_tag, "name"); ok {
name = n
}
long_map[name] = field
if s, ok := get_subtag(args_tag, "short"); ok {
short_map[s] = field
}
}
overflow_dyn := make([dynamic]string, 0, len(args), context.temp_allocator)
i := 0
for i < len(args) {
arg := args[i]
if strings.starts_with(arg, "--") {
key := arg[2:]
value := ""
has_value := false
if eq_idx := strings.index(key, "="); eq_idx >= 0 {
value = key[eq_idx + 1:]
key = key[:eq_idx]
has_value = true
}
if field, ok := long_map[key]; ok {
if is_bool_type(field) {
set_field(model, field, "")
i += 1
} else if has_value {
set_field(model, field, value)
i += 1
} else if i + 1 < len(args) && !strings.starts_with(args[i + 1], "-") {
set_field(model, field, args[i + 1])
i += 2
} else {
i += 1
}
} else {
i += 1
}
} else if strings.starts_with(arg, "-") && len(arg) == 2 {
short := arg[1:2]
if field, ok := short_map[short]; ok {
if is_bool_type(field) {
set_field(model, field, "")
i += 1
} else if i + 1 < len(args) && !strings.starts_with(args[i + 1], "-") {
set_field(model, field, args[i + 1])
i += 2
} else {
i += 1
}
} else {
i += 1
}
} else {
append(&overflow_dyn, arg)
i += 1
}
}
return overflow_dyn[:]
}

View File

@@ -11,12 +11,11 @@
}; };
outputs = outputs =
inputs@{ inputs@{ flake-parts
flake-parts, , nixpkgs
nixpkgs, , nixpkgs-unstable
nixpkgs-unstable, , self
self, , treefmt-nix
treefmt-nix,
}: }:
flake-parts.lib.mkFlake { inherit inputs; } { flake-parts.lib.mkFlake { inherit inputs; } {
imports = [ imports = [
@@ -30,18 +29,7 @@
]; ];
perSystem = perSystem =
{ { pkgs, system, inputs', ... }: {
pkgs,
system,
inputs',
...
}:
let
mysqlite = pkgs.sqlite.overrideAttrs (old: {
configureFlags = (old.configureFlags or [ ]) ++ [ "--enable-deserialize" ];
});
in
{
_module.args.pkgs = import nixpkgs { _module.args.pkgs = import nixpkgs {
inherit system; inherit system;
config.allowUnfree = true; config.allowUnfree = true;
@@ -66,7 +54,7 @@
packages.default = pkgs.stdenv.mkDerivation rec { packages.default = pkgs.stdenv.mkDerivation rec {
pname = "envr"; pname = "envr";
version = "0.3.0"; version = "0.2.0";
src = ./.; src = ./.;
nativeBuildInputs = [ nativeBuildInputs = [
@@ -75,19 +63,10 @@
]; ];
buildInputs = [ buildInputs = [
pkgs.git
pkgs.libsodium pkgs.libsodium
mysqlite pkgs.sqlite
]; ];
doCheck = true;
checkPhase = ''
runHook preCheck
odin test . -all-packages
runHook postCheck
'';
buildPhase = '' buildPhase = ''
runHook preBuild runHook preBuild
echo '${version}' > version.txt echo '${version}' > version.txt
@@ -104,20 +83,17 @@
devShells.default = pkgs.mkShell { devShells.default = pkgs.mkShell {
buildInputs = with pkgs; [ buildInputs = with pkgs; [
fd
nushell nushell
libsodium libsodium
mysqlite sqlite
unstable.odin unstable.odin
unstable.ols unstable.ols
# Build tools # Build tools
zip zip
# Helper tools
delta
hyperfine
# IDE # IDE
unstable.helix unstable.helix
typescript-language-server typescript-language-server

View File

@@ -1,57 +1,10 @@
package main package main
import "base:runtime"
import "core:fmt" import "core:fmt"
import "core:mem"
import "core:os" import "core:os"
import "core:prof/spall"
import "core:sync"
SPALL :: #config(SPALL, false)
when SPALL {
spall_ctx: spall.Context
@(thread_local)
spall_buffer: spall.Buffer
}
main :: proc() { main :: proc() {
when SPALL { cmd, ok := parse_args()
ctx, spall_ok := spall.context_create_with_scale("envr.spall", false, 1.0)
if !spall_ok {
fmt.eprintln("Failed to create spall trace file")
os.exit(1)
}
spall_ctx = ctx
defer spall.context_destroy(&spall_ctx)
spall_backing := make([]u8, spall.BUFFER_DEFAULT_SIZE)
defer delete(spall_backing)
spall_buffer = spall.buffer_create(spall_backing, u32(sync.current_thread_id()))
defer spall.buffer_destroy(&spall_ctx, &spall_buffer)
}
when ODIN_DEBUG {
heap_track: mem.Tracking_Allocator
mem.tracking_allocator_init(&heap_track, context.allocator)
defer mem.tracking_allocator_destroy(&heap_track)
defer if len(heap_track.allocation_map) > 0 {
for _, leak in heap_track.allocation_map {
fmt.eprintf("LEAK: %v leaked %m\n", leak.location, leak.size)
}
}
context.allocator = mem.tracking_allocator(&heap_track)
temp_track: mem.Tracking_Allocator
mem.tracking_allocator_init(&temp_track, context.temp_allocator)
defer mem.tracking_allocator_destroy(&temp_track)
context.temp_allocator = mem.tracking_allocator(&temp_track)
}
defer free_all(context.temp_allocator)
cmd, ok := parse_args(os.args, os.to_writer(os.stdout), os.to_writer(os.stderr))
defer delete_command(&cmd) // delete flushes automatically
if !ok { if !ok {
return return
} }
@@ -61,6 +14,8 @@ main :: proc() {
cmd_init(&cmd) cmd_init(&cmd)
case "version": case "version":
cmd_version(&cmd) cmd_version(&cmd)
case "deps":
cmd_deps(&cmd)
case "list": case "list":
cmd_list(&cmd) cmd_list(&cmd)
case "backup", "add": case "backup", "add":
@@ -80,27 +35,10 @@ main :: proc() {
case "nushell-completion": case "nushell-completion":
cmd_nushell_completion(&cmd) cmd_nushell_completion(&cmd)
case: case:
fmt.wprintf(cmd.err, "Unknown command: %s\n", cmd.name) fmt.printf("Unknown command: %s\n", cmd.name)
write_usage(cmd.out) print_usage()
os.exit(1) os.exit(1)
} }
} }
when SPALL {
@(instrumentation_enter)
spall_enter :: proc "contextless" (
proc_address, call_site_return_address: rawptr,
loc: runtime.Source_Code_Location,
) {
spall._buffer_begin(&spall_ctx, &spall_buffer, "", "", loc)
}
@(instrumentation_exit)
spall_exit :: proc "contextless" (
proc_address, call_site_return_address: rawptr,
loc: runtime.Source_Code_Location,
) {
spall._buffer_end(&spall_ctx, &spall_buffer)
}
}

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

@@ -2,115 +2,12 @@ package main
import "core:fmt" import "core:fmt"
import "core:sys/posix" import "core:sys/posix"
import "core:terminal/ansi"
MultiSelect_Result :: enum {
Confirm,
Cancel,
}
Key :: enum {
Up,
Down,
Space,
Enter,
Escape,
Unknown,
}
Raw_State :: struct { Raw_State :: struct {
original: posix.termios, original: posix.termios,
fd: posix.FD, fd: posix.FD,
} }
MAX_VISIBLE :: 7
// Caller is responsible for deleting the responses.
multi_select :: proc(
prompt: string,
options: []string,
) -> (
selected: [dynamic]bool,
result: MultiSelect_Result,
) {
if len(options) == 0 {
return
}
selected = make([dynamic]bool, len(options))
cursor: int = 0
scroll_offset: int = 0
fmt.printf(ansi.CSI + ansi.DECTCEM_HIDE)
visible := render_options(prompt, options, selected[:], cursor, scroll_offset)
raw, ok := enable_raw_mode(posix.STDIN_FILENO)
if !ok {
fmt.printf(ansi.CSI + ansi.DECTCEM_SHOW)
return
}
defer disable_raw_mode(&raw)
for {
key := read_key()
switch key {
case .Up:
if cursor > 0 {
cursor -= 1
}
case .Down:
if cursor < len(options) - 1 {
cursor += 1
}
case .Space:
selected[cursor] = !selected[cursor]
case .Enter:
fmt.printf(ansi.CSI + "%d" + ansi.CUU + ansi.CSI + ansi.ED + ansi.CSI + ansi.DECTCEM_SHOW, visible + 1)
result = .Confirm
return
case .Escape:
fmt.printf(ansi.CSI + "%d" + ansi.CUU + ansi.CSI + ansi.ED + ansi.CSI + ansi.DECTCEM_SHOW, visible + 1)
result = .Cancel
return
case .Unknown:
}
scroll_offset = max(0, min(cursor - MAX_VISIBLE / 2, len(options) - MAX_VISIBLE))
fmt.printf(ansi.CSI + "%d" + ansi.CUU + ansi.CSI + ansi.RESET + ansi.ED, visible + 1)
visible = render_options(prompt, options, selected[:], cursor, scroll_offset)
}
}
render_options :: proc(
prompt: string,
options: []string,
selected: []bool,
cursor: int,
scroll_offset: int,
) -> int {
fmt.printf(ansi.CSI + ansi.BOLD + ";" + ansi.FG_CYAN + ansi.SGR + "%s" + ANSI_RESET + " (↑/↓ move, space select, enter confirm)\r\n", prompt)
end := scroll_offset + MAX_VISIBLE
if end > len(options) {
end = len(options)
}
for i in scroll_offset ..< end {
checkbox := " "
if selected[i] {
checkbox = "x"
}
if i == cursor {
fmt.printf(ansi.CSI + ansi.BOLD + ";" + ansi.FG_GREEN + ansi.SGR + "> " + ANSI_RESET + "[" + ansi.CSI + ansi.FG_GREEN + ansi.SGR + "%s" + ANSI_RESET + "] %s\r\n", checkbox, options[i])
} else {
fmt.printf(" [" + ansi.CSI + ansi.FAINT + ansi.SGR + "%s" + ANSI_RESET + "] %s\r\n", checkbox, options[i])
}
}
return end - scroll_offset
}
enable_raw_mode :: proc(fd: posix.FD) -> (Raw_State, bool) { enable_raw_mode :: proc(fd: posix.FD) -> (Raw_State, bool) {
state: Raw_State state: Raw_State
state.fd = fd state.fd = fd
@@ -138,6 +35,15 @@ disable_raw_mode :: proc(state: ^Raw_State) {
posix.tcsetattr(state.fd, .TCSAFLUSH, &state.original) posix.tcsetattr(state.fd, .TCSAFLUSH, &state.original)
} }
Key :: enum {
Up,
Down,
Space,
Enter,
Escape,
Unknown,
}
read_key :: proc() -> Key { read_key :: proc() -> Key {
buf: [3]u8 buf: [3]u8
@@ -200,3 +106,88 @@ read_key :: proc() -> Key {
} }
} }
MultiSelect_Result :: enum {
Confirm,
Cancel,
}
MAX_VISIBLE :: 7
multi_select :: proc(
prompt: string,
options: []string,
) -> (selected: [dynamic]bool, result: MultiSelect_Result) {
if len(options) == 0 {
return
}
selected = make([dynamic]bool, len(options))
cursor: int = 0
scroll_offset: int = 0
fmt.printf("\x1b[?25l")
visible := render_options(prompt, options, selected[:], cursor, scroll_offset)
raw, ok := enable_raw_mode(posix.STDIN_FILENO)
if !ok {
fmt.printf("\x1b[?25h")
return
}
defer disable_raw_mode(&raw)
for {
key := read_key()
switch key {
case .Up:
if cursor > 0 {
cursor -= 1
}
case .Down:
if cursor < len(options) - 1 {
cursor += 1
}
case .Space:
selected[cursor] = !selected[cursor]
case .Enter:
fmt.printf("\x1b[%dA\x1b[J\x1b[?25h", visible + 1)
result = .Confirm
return
case .Escape:
fmt.printf("\x1b[%dA\x1b[J\x1b[?25h", visible + 1)
result = .Cancel
return
case .Unknown:
}
scroll_offset = max(0, min(cursor - MAX_VISIBLE / 2, len(options) - MAX_VISIBLE))
fmt.printf("\x1b[%dA\x1b[0J", visible + 1)
visible = render_options(prompt, options, selected[:], cursor, scroll_offset)
}
}
render_options :: proc(prompt: string, options: []string, selected: []bool, cursor: int, scroll_offset: int) -> int {
fmt.printf(
"\x1b[1;36m%s\x1b[0m (↑/↓ move, space select, enter confirm)\r\n",
prompt,
)
end := scroll_offset + MAX_VISIBLE
if end > len(options) {
end = len(options)
}
for i in scroll_offset..<end {
checkbox := " "
if selected[i] {
checkbox = "x"
}
if i == cursor {
fmt.printf("\x1b[1;32m> \x1b[0m[\x1b[32m%s\x1b[0m] %s\r\n", checkbox, options[i])
} else {
fmt.printf(" [\x1b[2m%s\x1b[0m] %s\r\n", checkbox, options[i])
}
}
return end - scroll_offset
}

128
scan.odin
View File

@@ -1,25 +1,139 @@
package main package main
import "core:fmt"
import "core:os" import "core:os"
import "core:strings"
import "core:sync"
import "core:terminal"
import "findr" fd_counter: sync.Atomic_Mutex
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) {
opts := findr.WalkOptions { if terminal.is_terminal(os.stdout) {
pattern = cfg.scan_config.matcher, fmt.printf("Searching for all files in \"%s\"...\n", search_path)
excludes = cfg.scan_config.exclude[:],
} }
findr.walk({search_path}, &paths, opts, os.get_processor_core_count()) all_files, all_ok := run_fd(build_fd_args(search_path, cfg, true))
if !all_ok {
return
}
if terminal.is_terminal(os.stdout) {
fmt.printf("Search for unignored fies in \"%s\"...\n", search_path)
}
unignored_files, unignored_ok := run_fd(build_fd_args(search_path, cfg, false))
if !unignored_ok {
return
}
unignored_set := make(map[string]bool, len(unignored_files), context.temp_allocator)
for file in unignored_files {
unignored_set[file] = true
}
for file in all_files {
if !(file in unignored_set) {
append(&paths, file)
}
}
ok = true ok = true
return return
} }
// The returned values live on the temp_allocator @(private = "file")
build_fd_args :: proc(search_path: string, cfg: Config, include_ignored: bool) -> []string {
args_len := 3 + 2 * len(cfg.ScanConfig.Exclude) + 2
args := make([dynamic]string, 0, args_len, context.temp_allocator)
append(&args, "fd")
append(&args, "-a")
append(&args, cfg.ScanConfig.Matcher)
for exclude in cfg.ScanConfig.Exclude {
append(&args, "-E")
append(&args, exclude)
}
if include_ignored {
append(&args, "-HI")
} else {
append(&args, "-H")
}
append(&args, search_path)
return args[:]
}
run_fd :: proc(args: []string) -> (lines: []string, ok: bool) {
tmp_path := next_fd_tmp_path()
tmp_file, tmp_err := os.open(tmp_path, os.O_CREATE | os.O_WRONLY | os.O_TRUNC)
if tmp_err != nil {
return
}
desc := os.Process_Desc {
command = args,
stdout = tmp_file,
stderr = nil,
}
p, start_err := os.process_start(desc)
os.close(tmp_file)
if start_err != nil {
os.remove(tmp_path)
return
}
state, wait_err := os.process_wait(p)
if wait_err != nil || state.exit_code != 0 {
os.remove(tmp_path)
return
}
data, read_err := os.read_entire_file_from_path(tmp_path, context.temp_allocator)
os.remove(tmp_path)
if read_err != nil {
return
}
output := string(data)
output = strings.trim_space(output)
if len(output) == 0 {
ok = true
return
}
raw_lines := strings.split(output, "\n", context.temp_allocator)
result := make([dynamic]string, 0, len(raw_lines), context.temp_allocator)
for line in raw_lines {
trimmed := strings.trim_space(line)
if len(trimmed) > 0 {
append(&result, trimmed)
}
}
return result[:], true
}
@(private = "file")
next_fd_tmp_path :: proc() -> string {
sync.atomic_mutex_lock(&fd_counter)
n := fd_seq
fd_seq += 1
sync.atomic_mutex_unlock(&fd_counter)
return fmt.tprintf("/tmp/envr-fd-%d-%d", os.get_pid(), n)
}
cant_scan :: proc(feats: AvailableFeatures) -> bool {
return Feature.Fd not_in feats
}
find_unbacked :: proc(local_files: []string, db_files: []EnvFile) -> []string { find_unbacked :: proc(local_files: []string, db_files: []EnvFile) -> []string {
// Lives until the end of the function
backed_set := make(map[string]bool, len(db_files), context.temp_allocator) backed_set := make(map[string]bool, len(db_files), context.temp_allocator)
for file in db_files { for file in db_files {
backed_set[file.path] = true backed_set[file.Path] = true
} }
unbacked := make([dynamic]string, 0, len(db_files) / 2, context.temp_allocator) unbacked := make([dynamic]string, 0, len(db_files) / 2, context.temp_allocator)

View File

@@ -1,4 +1,3 @@
#+test
package main package main
import "core:fmt" import "core:fmt"
@@ -8,7 +7,11 @@ import "core:testing"
@(test) @(test)
test_scan_path_finds_gitignored_env_files :: proc(t: ^testing.T) { test_scan_path_finds_gitignored_env_files :: proc(t: ^testing.T) {
base := test_temp_dir(t, "envr-scan-test-*") feats := check_features()
testing.expect(t, cant_scan(feats) == false)
base := fmt.tprintf("/tmp/envr-scan-test-%d", os.get_pid())
os.mkdir_all(base)
defer os.remove_all(base) defer os.remove_all(base)
git_init := os.Process_Desc { git_init := os.Process_Desc {
@@ -18,35 +21,27 @@ test_scan_path_finds_gitignored_env_files :: proc(t: ^testing.T) {
stderr = os.stderr, stderr = os.stderr,
} }
p, err := os.process_start(git_init) p, err := os.process_start(git_init)
testing.expectf(t, err == nil, "Failed to run git: %v", err) if err != nil {
if err != nil do return return
state, wait_err := os.process_wait(p) }
testing.expectf(t, wait_err == nil, "Failed to wait: %v", wait_err) _, wait_err := os.process_wait(p)
if wait_err != nil do return if wait_err != nil {
testing.expect(t, state.success, "command should succeed") return
}
gitignore_path := fmt.tprintf("%s/.gitignore", base) gitignore_path := fmt.tprintf("%s/.gitignore", base)
err = os.write_entire_file(gitignore_path, ".env*\n") _ = os.write_entire_file(gitignore_path, ".env*\n")
testing.expectf(t, err == nil, "Failed: %v", err)
err = os.write_entire_file(fmt.tprintf("%s/.env", base), "SECRET=1") _ = os.write_entire_file(fmt.tprintf("%s/.env", base), "SECRET=1")
testing.expectf(t, err == nil, "Failed: %v", err) _ = os.write_entire_file(fmt.tprintf("%s/.env.testing", base), "TEST=1")
err = os.write_entire_file(fmt.tprintf("%s/.env.testing", base), "TEST=1") _ = os.write_entire_file(fmt.tprintf("%s/config.yaml", base), "key: value")
testing.expectf(t, err == nil, "Failed: %v", err)
err = os.write_entire_file(fmt.tprintf("%s/config.yaml", base), "key: value")
testing.expectf(t, err == nil, "Failed: %v", err)
cfg := Config { cfg := Config {
scan_config = ScanConfig{matcher = "\\.env"}, ScanConfig = ScanConfig{Matcher = "\\.env"},
} }
results, ok := scan_path(base, cfg) results, ok := scan_path(base, cfg)
defer { defer delete(results)
for path in results {
delete(path)
}
delete(results)
}
testing.expect(t, ok, "scan_path should succeed") testing.expect(t, ok, "scan_path should succeed")
found_env := false found_env := false
@@ -73,16 +68,20 @@ test_scan_path_finds_gitignored_env_files :: proc(t: ^testing.T) {
@(test) @(test)
test_scan_path_empty_dir :: proc(t: ^testing.T) { test_scan_path_empty_dir :: proc(t: ^testing.T) {
base := test_temp_dir(t, "envr-scan-empty-*") feats := check_features()
testing.expect(t, cant_scan(feats) == false)
base := fmt.tprintf("/tmp/envr-scan-empty-%d", os.get_pid())
os.mkdir_all(base)
defer os.remove_all(base) defer os.remove_all(base)
cfg := Config { cfg := Config {
scan_config = ScanConfig{matcher = "\\.env"}, ScanConfig = ScanConfig{Matcher = "\\.env"},
} }
results, ok := scan_path(base, cfg) results, ok := scan_path(base, cfg)
defer delete(results) defer delete(results)
testing.expect(t, ok, "scan_path should succeed") testing.expect(t, ok, "scan_path should succeed")
testing.expect_value(t, len(results), 0) testing.expect(t, len(results) == 0, fmt.tprintf("expected 0 results, got %d", len(results)))
} }

View File

@@ -4,57 +4,31 @@ import "core:c"
foreign import lib "system:sqlite3" foreign import lib "system:sqlite3"
Db :: distinct rawptr
Stmt :: distinct rawptr
// TODO: Use an enum?
OK :: 0 OK :: 0
ROW :: 100 ROW :: 100
DONE :: 101 DONE :: 101
DESERIALIZE_FLAGS :: bit_set[DESERIALIZE_FLAG]
DESERIALIZE_FLAG :: enum u32 {
FREEONCLOSE = 1,
RESIZEABLE = 2,
READONLY = 4,
}
SERIALIZE_FLAGS :: bit_set[SERIALIZE_FLAG]
SERIALIZE_FLAG :: enum u32 {
NOCOPY = 1,
}
foreign lib { foreign lib {
@(link_name="sqlite3_open") @(link_name="sqlite3_open")
open :: proc(filename: cstring, ppDb: ^Db) -> c.int --- db_open :: proc(filename: cstring, ppDb: ^^rawptr) -> c.int ---
@(link_name="sqlite3_close") @(link_name="sqlite3_close")
close :: proc(db: Db) -> c.int --- db_close :: proc(db: ^rawptr) -> c.int ---
@(link_name="sqlite3_errmsg") @(link_name="sqlite3_errmsg")
errmsg :: proc(db: Db) -> cstring --- db_errmsg :: proc(db: ^rawptr) -> cstring ---
@(link_name="sqlite3_exec") @(link_name="sqlite3_exec")
exec :: proc(db: Db, sql: cstring, callback: rawptr, callback_arg: rawptr, errmsg: ^cstring) -> c.int --- db_exec :: proc(db: ^rawptr, sql: cstring, callback: rawptr, callback_arg: rawptr, errmsg: ^cstring) -> c.int ---
@(link_name="sqlite3_prepare_v2") @(link_name="sqlite3_prepare_v2")
prepare_v2 :: proc(db: Db, sql: cstring, nByte: c.int, ppStmt: ^Stmt, pzTail: ^cstring) -> c.int --- prepare_v2 :: proc(db: ^rawptr, sql: cstring, nByte: c.int, ppStmt: ^^rawptr, pzTail: ^cstring) -> c.int ---
@(link_name="sqlite3_step") @(link_name="sqlite3_step")
step :: proc(stmt: Stmt) -> c.int --- step :: proc(stmt: ^rawptr) -> c.int ---
@(link_name="sqlite3_finalize") @(link_name="sqlite3_finalize")
finalize :: proc(stmt: Stmt) -> c.int --- finalize :: proc(stmt: ^rawptr) -> c.int ---
@(link_name="sqlite3_column_text") @(link_name="sqlite3_column_text")
column_text :: proc(stmt: Stmt, iCol: c.int) -> cstring --- column_text :: proc(stmt: ^rawptr, iCol: c.int) -> cstring ---
@(link_name="sqlite3_column_bytes") @(link_name="sqlite3_column_bytes")
column_bytes :: proc(stmt: Stmt, iCol: c.int) -> c.int --- column_bytes :: proc(stmt: ^rawptr, iCol: c.int) -> c.int ---
@(link_name="sqlite3_bind_text") @(link_name="sqlite3_bind_text")
bind_text :: proc(stmt: Stmt, idx: c.int, val: cstring, n: c.int, destructor: rawptr) -> c.int --- bind_text :: proc(stmt: ^rawptr, idx: c.int, val: cstring, n: c.int, destructor: rawptr) -> c.int ---
@(link_name="sqlite3_changes") @(link_name="sqlite3_changes")
changes :: proc(db: Db) -> c.int --- changes :: proc(db: ^rawptr) -> c.int ---
@(link_name = "sqlite3_serialize")
serialize :: proc(db: Db, zSchema: cstring, piSize: ^i64, mFlags: SERIALIZE_FLAGS) -> [^]u8 ---
@(link_name = "sqlite3_deserialize")
deserialize :: proc(db: Db, zSchema: cstring, pData: [^]u8, szDb: i64, szBuf: i64, mFlags: DESERIALIZE_FLAGS) -> c.int ---
@(link_name = "sqlite3_malloc64")
malloc64 :: proc(n: i64) -> [^]u8 ---
@(link_name = "sqlite3_free")
free :: proc(p: rawptr) ---
} }

138
ssh.odin
View File

@@ -1,10 +1,7 @@
package main package main
import "base:runtime"
import "core:encoding/base64" import "core:encoding/base64"
import "core:encoding/endian"
import "core:fmt" import "core:fmt"
import "core:mem"
import "core:os" import "core:os"
import "core:strings" import "core:strings"
@@ -15,6 +12,24 @@ Ed25519Keypair :: struct {
Private: [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) { 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) data, err := os.read_entire_file_from_path(pub_path, context.temp_allocator)
if err != nil { if err != nil {
@@ -46,7 +61,9 @@ parse_ssh_public_key :: proc(pub_path: string) -> (pub: [32]u8, ok: bool) {
return return
} }
mem.copy_non_overlapping(&pub[0], raw_data(pk_data), 32) for i in 0 ..< 32 {
pub[i] = pk_data[i]
}
ok = true ok = true
return return
@@ -86,10 +103,15 @@ parse_ssh_private_key :: proc(priv_path: string) -> (kp: Ed25519Keypair, ok: boo
return return
} }
magic :: "openssh-key-v1\x00" magic := "openssh-key-v1\x00"
if !strings.has_prefix(string(decoded), magic) { if len(decoded) < len(magic) {
return return
} }
for i in 0 ..< len(magic) {
if decoded[i] != u8(magic[i]) {
return
}
}
offset := len(magic) offset := len(magic)
@@ -111,8 +133,8 @@ parse_ssh_private_key :: proc(priv_path: string) -> (kp: Ed25519Keypair, ok: boo
if offset + 4 > len(decoded) { if offset + 4 > len(decoded) {
return return
} }
num_keys := u32(decoded[offset]) << 24 | u32(decoded[offset + 1]) << 16 |
num_keys := endian.get_u32(decoded[offset:offset + 4], .Big) or_return u32(decoded[offset + 2]) << 8 | u32(decoded[offset + 3])
offset += 4 offset += 4
if num_keys != 1 { if num_keys != 1 {
@@ -133,16 +155,11 @@ parse_ssh_private_key :: proc(priv_path: string) -> (kp: Ed25519Keypair, ok: boo
if inner_offset + 8 > len(priv_blob) { if inner_offset + 8 > len(priv_blob) {
return return
} }
check1 := u32(priv_blob[inner_offset]) << 24 | u32(priv_blob[inner_offset + 1]) << 16 |
check1 := endian.get_u32( u32(priv_blob[inner_offset + 2]) << 8 | u32(priv_blob[inner_offset + 3])
transmute([]u8)(priv_blob)[inner_offset:inner_offset + 4],
.Big,
) or_return
inner_offset += 4 inner_offset += 4
check2 := endian.get_u32( check2 := u32(priv_blob[inner_offset]) << 24 | u32(priv_blob[inner_offset + 1]) << 16 |
transmute([]u8)(priv_blob)[inner_offset:inner_offset + 4], u32(priv_blob[inner_offset + 2]) << 8 | u32(priv_blob[inner_offset + 3])
.Big,
) or_return
inner_offset += 4 inner_offset += 4
if check1 != check2 { if check1 != check2 {
@@ -158,44 +175,81 @@ parse_ssh_private_key :: proc(priv_path: string) -> (kp: Ed25519Keypair, ok: boo
if !pub_ok || len(pub_wire) != 32 { if !pub_ok || len(pub_wire) != 32 {
return return
} }
mem.copy_non_overlapping(&kp.Public[0], raw_data(pub_wire), 32) for i in 0 ..< 32 {
kp.Public[i] = pub_wire[i]
}
priv_wire, priv_ok := read_wire_string(transmute([]u8)priv_blob, &inner_offset) priv_wire, priv_ok := read_wire_string(transmute([]u8)priv_blob, &inner_offset)
if !priv_ok || len(priv_wire) != 64 { if !priv_ok || len(priv_wire) != 64 {
return return
} }
for i in 0 ..< 32 {
mem.copy_non_overlapping(&kp.Private[0], raw_data(priv_wire), 32) kp.Private[i] = priv_wire[i]
}
ok = true ok = true
return return
} }
is_ed25519_key :: proc( is_ed25519_key :: proc(priv_path: string) -> bool {
priv_path: string, pub_path, _ := strings.concatenate([]string{priv_path, ".pub"}, context.temp_allocator)
) -> ( _, ok := parse_ssh_public_key(pub_path)
ok: bool, return ok
err: runtime.Allocator_Error,
) #optional_allocator_error {
pub_path := strings.concatenate([]string{priv_path, ".pub"}, context.temp_allocator) or_return
_, ok = parse_ssh_public_key(pub_path)
return ok, nil
} }
read_wire_string :: proc(data: []u8, offset: ^int) -> (s: string, ok: bool) { is_encrypted_key :: proc(priv_path: string) -> bool {
if offset^ + 4 > len(data) { data, err := os.read_entire_file_from_path(priv_path, context.temp_allocator)
return if err != nil {
} return true
length := endian.get_u32(data[offset^:offset^ + 4], .Big) or_return
offset^ += 4
if offset^ + int(length) > len(data) {
return
} }
s = string(data[offset^:offset^ + int(length)]) if !strings.contains(string(data), "BEGIN OPENSSH PRIVATE KEY") {
offset^ += int(length) return true
ok = true
return
} }
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"
}

View File

@@ -1,11 +1,9 @@
#+test
package main package main
import "core:fmt" import "core:fmt"
import "core:os"
import "core:testing" import "core:testing"
TEST_KEY_DIR :: "fixtures" + os.Path_Separator_String + "keys" TEST_KEY_DIR :: "fixtures/keys"
@(test) @(test)
test_parse_ed25519_public_key :: proc(t: ^testing.T) { test_parse_ed25519_public_key :: proc(t: ^testing.T) {
@@ -46,7 +44,15 @@ test_private_key_pub_matches_public_key :: proc(t: ^testing.T) {
kp, priv_ok := parse_ssh_private_key(TEST_KEY_DIR + "/test_ed25519") 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, priv_ok, "expected private key to parse")
testing.expect_value(t, pub_from_pub, kp.Public) 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)
@@ -56,11 +62,47 @@ test_read_wire_string :: proc(t: ^testing.T) {
s, ok := read_wire_string(data, &offset) s, ok := read_wire_string(data, &offset)
testing.expect(t, ok, "expected read_wire_string to succeed") testing.expect(t, ok, "expected read_wire_string to succeed")
testing.expect_value(t, s, "hello") testing.expect(t, s == "hello", fmt.tprintf("expected 'hello', got %q", s))
testing.expect_value(t, offset, 9) testing.expect(t, offset == 9, fmt.tprintf("expected offset 9, got %d", offset))
s2, ok2 := read_wire_string(data, &offset) s2, ok2 := read_wire_string(data, &offset)
testing.expect(t, ok2, "expected second read to succeed") testing.expect(t, ok2, "expected second read to succeed")
testing.expect_value(t, s2, "") 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

@@ -1,75 +1,89 @@
package main package main
import "core:encoding/json"
import "core:fmt" import "core:fmt"
import "core:io" import "core:io"
import "core:text/table" import "core:strings"
decorations := table.Decorations { render_table :: proc(w: io.Writer, headers: []string, rows: [][]string) {
"┌", col_widths := make([dynamic]int, 0, len(headers))
"┬", for i in 0 ..< len(headers) {
"┐", append(&col_widths, strings.rune_count(headers[i]))
"├", }
"┼", for r in rows {
"┤", for i in 0 ..< len(r) {
"└", rw := strings.rune_count(r[i])
"┴", if i < len(col_widths) && rw > col_widths[i] {
"┘", col_widths[i] = rw
"│", }
"─", }
} }
ansi_aware_width :: proc(str: string) -> int #no_bounds_check { b: strings.Builder
width := 0 strings.builder_init(&b)
for i := 0; i < len(str); { defer strings.builder_destroy(&b)
if i + 1 < len(str) && str[i] == 0x1b && str[i + 1] == '[' { defer delete(col_widths)
i += 2
for i < len(str) {c := str[i]; i += 1; if c >= 0x40 && c <= 0x7E {break}} hline :: proc(w: io.Writer, b: ^strings.Builder, left, mid, right: string, widths: [dynamic]int) {
strings.write_string(b, left)
for i in 0 ..< len(widths) {
for _ in 0 ..< widths[i] + 2 {
strings.write_string(b, "\u2500")
}
if i < len(widths) - 1 {
strings.write_string(b, mid)
} else { } else {
width += 1 strings.write_string(b, right)
i += 1
} }
} }
return width fmt.wprintf(w, "%s\n", strings.to_string(b^), flush = false)
strings.builder_reset(b)
} }
write_borderless_table :: proc(w: io.Writer, t: ^table.Table) { hline(w, &b, "\u250c", "\u252c", "\u2510", col_widths)
table.build(t, ansi_aware_width)
write_table_separator :: proc(w: io.Writer, tbl: ^table.Table) { cell :: proc(b: ^strings.Builder, s: string, width: int) {
io.write_byte(w, '\n') extra := len(s) - strings.rune_count(s)
fmt.sbprintf(b, " %-*s \u2502", width + extra, s)
} }
if t.caption != "" { strings.write_string(&b, "\u2502")
table.write_text_align( for i in 0 ..< len(headers) {
w, cell(&b, headers[i], col_widths[i])
fmt.tprintf("%s%s%s", COLOR_HEADINGS, t.caption, ANSI_RESET), }
.Left, fmt.wprintf(w, "%s\n", strings.to_string(b), flush = false)
0, //t.lpad, strings.builder_reset(&b)
0, //t.rpad,
t.tblw + t.nr_cols - 1 - ansi_aware_width(t.caption) - t.lpad - t.rpad, hline(w, &b, "\u251c", "\u253c", "\u2524", col_widths)
)
io.write_byte(w, '\n') for r in rows {
strings.write_string(&b, "\u2502")
for i in 0 ..< len(r) {
cell(&b, r[i], col_widths[i])
}
fmt.wprintf(w, "%s\n", strings.to_string(b), flush = false)
strings.builder_reset(&b)
} }
write_table_separator(w, t) hline(w, &b, "\u2514", "\u2534", "\u2518", col_widths)
for row in 0 ..< t.nr_rows {
for col in 0 ..< t.nr_cols {
table.write_table_cell(w, t, row, col)
}
io.write_byte(w, '\n')
if t.has_header_row && row == table.header_row(t) {
write_table_separator(w, t)
}
}
write_table_separator(w, t)
} }
table_reset :: proc(t: ^table.Table) { render_json_rows :: proc(w: io.Writer, headers: []string, rows: [][]string) {
clear(&t.cells) entries := make([dynamic]map[string]string, 0, len(rows), context.temp_allocator)
clear(&t.colw)
t.caption = "" for row in rows {
t.tblw = 0 entry := make(map[string]string, len(headers), context.temp_allocator)
t.nr_cols = 0 for i in 0 ..< len(headers) {
t.nr_rows = 0 entry[headers[i]] = row[i]
}
append(&entries, entry)
}
data, err := json.marshal(entries[:], allocator = context.temp_allocator)
if err != nil {
fmt.eprintf("Error marshaling JSON: %v\n", err)
return
}
fmt.wprintf(w, "%s", data, flush = false)
} }

View File

@@ -1,33 +1,161 @@
#+test
package main package main
import "core:encoding/json"
import "core:fmt"
import "core:strings"
import "core:testing" import "core:testing"
@(test) @(test)
test_ansi_aware_width_plain_ascii :: proc(t: ^testing.T) { test_render_json_rows_normal :: proc(t: ^testing.T) {
testing.expect_value(t, ansi_aware_width("hello"), 5) b: strings.Builder
strings.builder_init(&b)
defer strings.builder_destroy(&b)
headers := []string{"name", "path"}
rows := [][]string{{"foo", "/home/user/.env"}, {"bar", "/home/user/project/.env"}}
w := strings.to_writer(&b)
render_json_rows(w, headers, rows)
output := strings.to_string(b)
result: []map[string]string = ---
unmarshal_err := json.unmarshal_string(output, &result, allocator = context.temp_allocator)
testing.expect(
t,
unmarshal_err == nil,
fmt.tprintf("json unmarshal failed: %v\noutput was: %q", unmarshal_err, output),
)
testing.expect(t, len(result) == 2, fmt.tprintf("expected 2 rows, got %d", len(result)))
testing.expect(
t,
result[0]["name"] == "foo",
fmt.tprintf("expected name=foo, got %q", result[0]["name"]),
)
testing.expect(t, result[0]["path"] == "/home/user/.env")
testing.expect(t, result[1]["name"] == "bar")
testing.expect(t, result[1]["path"] == "/home/user/project/.env")
} }
@(test) @(test)
test_ansi_aware_width_empty :: proc(t: ^testing.T) { test_render_json_rows_special_chars :: proc(t: ^testing.T) {
testing.expect_value(t, ansi_aware_width(""), 0) b: strings.Builder
strings.builder_init(&b)
defer strings.builder_destroy(&b)
headers := []string{"key", "value"}
rows := [][]string {
{"quote", `has "double quotes"`},
{"backslash", `path\to\file`},
{"newline", "line1\nline2"},
{"mixed", `a "b" c\nd`},
}
w := strings.to_writer(&b)
render_json_rows(w, headers, rows)
output := strings.to_string(b)
result: []map[string]string = ---
unmarshal_err := json.unmarshal(
transmute([]byte)output,
&result,
allocator = context.temp_allocator,
)
testing.expect(
t,
unmarshal_err == nil,
fmt.tprintf("json unmarshal failed: %v\noutput was: %q", unmarshal_err, output),
)
testing.expect(t, len(result) == 4)
testing.expect(
t,
result[0]["value"] == `has "double quotes"`,
fmt.tprintf("got %q", result[0]["value"]),
)
testing.expect(t, result[1]["value"] == `path\to\file`)
testing.expect(t, result[2]["value"] == "line1\nline2")
testing.expect(t, result[3]["value"] == `a "b" c\nd`)
} }
@(test) @(test)
test_ansi_aware_width_with_color_codes :: proc(t: ^testing.T) { test_render_json_rows_empty :: proc(t: ^testing.T) {
colored := COLOR_TABLE_HEADING + "Directory" + ANSI_RESET b: strings.Builder
testing.expect_value(t, ansi_aware_width(colored), 9) strings.builder_init(&b)
defer strings.builder_destroy(&b)
headers := []string{"name"}
rows: [][]string
w := strings.to_writer(&b)
render_json_rows(w, headers, rows)
output := strings.to_string(b)
result: []map[string]string = ---
unmarshal_err := json.unmarshal_string(output, &result, allocator = context.temp_allocator)
testing.expect(
t,
unmarshal_err == nil,
fmt.tprintf("json unmarshal failed: %v\noutput was: %q", unmarshal_err, output),
)
testing.expect(t, len(result) == 0)
} }
@(test) @(test)
test_ansi_aware_width_multibyte :: proc(t: ^testing.T) { test_render_table_normal :: proc(t: ^testing.T) {
testing.expect_value(t, ansi_aware_width("\u2713 Available"), 13) b: strings.Builder
testing.expect_value(t, ansi_aware_width("\u2717 Missing"), 11) 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)
test_ansi_aware_width_multiple_escape_sequences :: proc(t: ^testing.T) { test_render_table_empty :: proc(t: ^testing.T) {
colored := COLOR_TABLE_HEADING + "a" + ANSI_RESET + "b" + COLOR_TABLE_HEADING + "c" + ANSI_RESET b: strings.Builder
testing.expect_value(t, ansi_aware_width(colored), 3) 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")
}

Binary file not shown.

View File

@@ -1 +1 @@
0.3.0 0.2.0