1 Commits

Author SHA1 Message Date
Spencer Brower
312a5e9a00 chore(main): release 0.3.1 2026-06-17 17:58:13 -04:00
53 changed files with 2461 additions and 2293 deletions

View File

@@ -2,6 +2,8 @@ on:
push: push:
branches: branches:
- main - main
- dev
- odin
permissions: permissions:
contents: write contents: write

1
.gitignore vendored
View File

@@ -11,7 +11,6 @@ man
builds builds
envr envr
envr-go envr-go
envr-prof
findr/findr findr/findr
findr/findr-prof findr/findr-prof
findr/bench-*.md findr/bench-*.md

View File

@@ -1,5 +1,12 @@
# Changelog # Changelog
## [0.3.1](https://github.com/sbrow/envr/compare/v0.3.0...v0.3.1) (2026-06-17)
### Performance Improvements
* Replaced `fd` with custom internals. ([2ef733f](https://github.com/sbrow/envr/commit/2ef733fe58594b0a0b6e3ef85142b74af445ccb8))
## [0.3.0](https://github.com/sbrow/envr/compare/v0.2.1...v0.3.0) (2026-06-16) ## [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. Version 0.3.0 represents a significant departure (and improvement) for envr.

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

@@ -35,7 +35,13 @@ Stdout will be captured by redirecting `os.stdout` to a pipe.
## Hard to test (interactive / external deps) ## Hard to test (interactive / external deps)
### `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)
### `cmd_scan` (cmd_scan.odin) ### `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
@@ -60,4 +66,5 @@ Stdout will be captured by redirecting `os.stdout` to a pipe.
- 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`.
- External dependency tests (`fd`, `git`) should use `#assert` to ensure the dependency is present rather than silently skipping (TODO 28).
- Tests that manipulate the `HOME` env var must use a mutex to prevent races with parallel test execution. - Tests that manipulate the `HOME` env var must use a mutex to prevent races with parallel test execution.

View File

@@ -1,44 +1,54 @@
# TODOs # TODOs
1. Bring back windows support / cross-compilation. 1. Consider giving db its own allocator
2. Commands are still leaking. (Write tests for everything first) 2. **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.
3. procedures should be ordered by use, main at the top, then in the order they are called from main. 3. **db.odin:135, 250** — String interpolation into SQL (`VACUUM INTO '%s'`, `ATTACH DATABASE '%s'`). Currently safe because input is controlled, but fragile.
4. Check for prealloc opportunities. i.e. `make([dynamic]string)` -> `make([dynamic]string, 5)`. 4. **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).
5. Test all cmds / terminal branches. 5. **cmd_restore.odin:20-30 & cmd_remove.odin:19-29** — Identical path-resolution block copy-pasted. `is_abs` guard is redundant since `filepath.abs` is a no-op on absolute paths. Extract a helper.
6. Generate md and man pages again. 6. **cmd_restore.odin:44**`os.mkdir_all` error silently discarded. Subsequent write failure will be confusing.
7. Shell completion 8. **config.odin:178**`search_paths` silently ignores `os.user_home_dir` error. If home is empty, `~` isn't expanded. Same class of bug as issue 3.
8. Add tests for untested commands. 10. **db.odin:115**`json.unmarshal_string` error not checked. Malformed JSON silently produces empty/partial data.
9. Update `read_wire_string` to use a slice. 11. **db.odin:352-353**`hex.encode` error ignored. `string(hex_bytes)` aliases the byte slice.
10. Pass allocator to findr? 12. **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.
11. Smarter flag parsing? 13. **cmd_list.odin** — Non-TTY branch builds `ListEntry` structs and marshals JSON separately. Now that `render_json_rows` (issue 1) accepts an `io.Writer` and uses `json.marshal`, unify both branches to use it. Note: will change JSON keys from `"directory"/"path"` to `"Directory"/"Path"`.
12. Rewrite `write_command_help` to use text/tables 14. Check for prealloc opportunities. i.e. `make([dynamic]string)` -> `make([dynamic]string, 5)`.
13. Instead of using a writer to strip colors, just don't print the colors. 15. Add a text filter to the multi_select.
14. Add a text filter to the multi_select. 16. Create backup / fallback fd.
15. init -h doesn't show --force flag. Separate into multiple structs: Global_FLags, and Init_Flags? 17. Add tests for untested commands.
18. 2 scan tests silently skip when fd isn't installed, tests pass without actually testing anything. These should use #assert to be sure that fd is in path.
20. add --format -f flag to commands that draw tables.
21. Replace `testing.expect` calls with `testing.expect_value` calls where appropriate.
22. Change struct field names from PascalCase to snake_case.
23. procedures should be ordered by use, main at the top, then in the order they are called from main.
## Double-check AI output ## Double-check AI output
- [ ] cli.odin - [ ] cli.odin
- [ ] cli_test.odin - [ ] cli_test.odin
- [x] colors.odin
- [x] cmd_backup.odin - [x] cmd_backup.odin
- [x] cmd_check.odin - [x] cmd_check.odin
- [ ] cmd_check_test.odin - [ ] cmd_check_test.odin
- [x] cmd_edit_config.odin - [x] cmd_deps.odin
- [ ] cmd_edit_config.odin
- [x] cmd_init.odin - [x] cmd_init.odin
- [x] cmd_list.odin - [x] cmd_list.odin
- [ ] cmd_list_test.odin - [ ] cmd_list_test.odin
@@ -49,29 +59,22 @@
- [x] cmd_scan.odin - [x] cmd_scan.odin
- [x] cmd_sync.odin - [x] 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 - [x] features.odin
- [x] features_test.odin
- [x] main.odin - [x] main.odin
- [x] prompt.odin - [x] prompt.odin
- [x] scan.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

298
cli.odin
View File

@@ -5,37 +5,16 @@ import "core:fmt"
import "core:io" import "core:io"
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, config_path: string,
err: io.Writer, out_buf: ^bufio.Writer,
} 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 {
@@ -64,6 +43,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,12 +61,20 @@ key somewhere, otherwise your data could be lost forever.`,
}, },
} }
delete_command :: proc(cmd: ^Command) {
delete(cmd.args)
delete(cmd.flags)
delete(cmd.bool_set)
bufio.writer_destroy(cmd.out_buf)
free(cmd.out_buf)
}
// Caller is responsible for calling delete_command(cmd). // Caller is responsible for calling delete_command(cmd).
// FIXME: Works in kinda a wonky and awkward way. // FIXME: Works in kinda a wonky and awkward way.
parse_args :: proc(args: []string, out: io.Stream, err: io.Stream) -> (cmd: Command, ok: bool) { parse_args :: proc(args: []string, out: io.Stream, err: io.Stream) -> (cmd: Command, ok: bool) {
{ {
cmd.out_buf = new(bufio.Writer) cmd.out_buf = new(bufio.Writer)
bufio.writer_init(cmd.out_buf, out, allocator = context.allocator) bufio.writer_init(cmd.out_buf, out)
cmd.out = bufio.writer_to_writer(cmd.out_buf) cmd.out = bufio.writer_to_writer(cmd.out_buf)
cmd.err = err cmd.err = err
} }
@@ -91,33 +85,51 @@ parse_args :: proc(args: []string, out: io.Stream, err: io.Stream) -> (cmd: Comm
} }
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) {
append(&cmd.args, arg) arg := args[i]
if strings.starts_with(arg, "--") {
key := arg[2:]
if i + 1 < len(args) && !strings.starts_with(args[i + 1], "-") {
cmd.flags[key] = args[i + 1]
i += 2
} else {
cmd.bool_set[key] = true
i += 1
}
} else if strings.starts_with(arg, "-") && len(arg) == 2 {
key_slice := arg[1:2]
if i + 1 < len(args) && !strings.starts_with(args[i + 1], "-") {
cmd.flags[key_slice] = args[i + 1]
i += 2
} else {
cmd.bool_set[key_slice] = true
i += 1
}
} else {
append(&cmd.args, arg)
i += 1
}
} }
if cmd.flags.output == .Auto { if val, ok := cmd.flags["config-file"]; ok {
cmd.flags.output = terminal.is_terminal(os.stdout) ? .Table : .JSON cmd.config_path = val
} } else if val, ok := cmd.flags["c"]; ok {
cmd.config_path = val
if cmd.flags.color == .Auto { } else {
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 // FIXME: Handle err
// TODO: Is this right? // TODO: Is this right?
home, _ := os.user_home_dir(context.temp_allocator) home, _ := os.user_home_dir(context.temp_allocator)
// TODO: should we copy out of the temp_allocator? // TODO: should we copy out of the temp_allocator?
cmd.flags.config_file = default_config_path(home, context.temp_allocator) cmd.config_path = default_config_path(home, context.temp_allocator)
} }
if cmd.flags.help { if has_flag(&cmd, "help") {
print_command_help(&cmd) print_command_help(&cmd)
return cmd, false return cmd, false
} }
@@ -125,80 +137,13 @@ parse_args :: proc(args: []string, out: io.Stream, err: io.Stream) -> (cmd: Comm
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) return true
write_usage(cmd.out)
} }
} _, ok2 := cmd.bool_set[name]
return ok2
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
} }
find_command :: proc(name: string) -> (CommandInfo, bool) { find_command :: proc(name: string) -> (CommandInfo, bool) {
@@ -215,15 +160,53 @@ find_command :: proc(name: string) -> (CommandInfo, bool) {
return CommandInfo{}, false return CommandInfo{}, false
} }
write_command_help :: proc(name: string, w: io.Writer) -> bool {
info, found := find_command(name)
if !found {
return false
}
fmt.wprintf(w, "Usage: %s [flags]\n\n", info.usage, flush = false)
fmt.wprintf(w, "%s\n", info.short, flush = false)
if len(info.aliases) > 0 {
fmt.wprintf(w, "\nAliases:\n %s", info.name, flush = false)
for a in info.aliases {
fmt.wprintf(w, ", %s", a, flush = false)
}
fmt.wprintf(w, "\n", flush = false)
}
if len(info.long) > 0 {
fmt.wprintf(w, "\n%s\n", info.long, flush = false)
}
fmt.wprintf(
w,
"\nFlags:\n -h, --help help for %s\n -c, --config-file <path> config file (default \"~/.envr/config.json\")\n",
info.name,
flush = false,
)
return true
}
print_command_help :: proc(cmd: ^Command) {
ok := write_command_help(cmd.name, cmd.out)
if !ok {
fmt.wprintf(cmd.err, "Unknown command: %s\n", cmd.name)
write_usage(cmd.out)
}
}
// TODO: command args should be shown in usage. // TODO: command args should be shown in usage.
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 +233,40 @@ 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
-c, --config-file <path> config file (default "~/.envr/config.json")
Use "envr [command] --help" for more information about a command.
`,
flush = false, flush = false,
) )
} }
delete_command :: proc(cmd: ^Command) {
bufio.writer_flush(cmd.out_buf)
delete(cmd.args)
bufio.writer_destroy(cmd.out_buf)
free(cmd.out_buf)
}

View File

@@ -1,6 +1,5 @@
#+feature dynamic-literals #+feature dynamic-literals
package main package main
package main
import "core:bufio" import "core:bufio"
import "core:fmt" import "core:fmt"
@@ -58,7 +57,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 +122,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,6 +143,53 @@ test_command_help_version :: proc(t: ^testing.T) {
} }
@(test) @(test)
test_has_flag_bool_set :: proc(t: ^testing.T) {
cmd := Command {
name = "test",
bool_set = map[string]bool{"force" = true},
}
defer delete(cmd.bool_set)
testing.expect(t, has_flag(&cmd, "force"), "should find flag in bool_set")
testing.expect(t, !has_flag(&cmd, "verbose"), "should not find missing flag")
}
@(test)
test_has_flag_value_map :: proc(t: ^testing.T) {
cmd := Command {
name = "test",
flags = map[string]string{"output" = "/tmp/out"},
}
defer delete(cmd.flags)
testing.expect(t, has_flag(&cmd, "output"), "should find flag in flags map")
testing.expect(t, !has_flag(&cmd, "force"), "should not find missing flag")
}
@(test)
test_has_flag_both_maps :: proc(t: ^testing.T) {
cmd := Command {
name = "test",
flags = map[string]string{"output" = "/tmp/out"},
bool_set = map[string]bool{"force" = true},
}
defer delete(cmd.flags)
defer delete(cmd.bool_set)
testing.expect(t, has_flag(&cmd, "output"), "should find in flags")
testing.expect(t, has_flag(&cmd, "force"), "should find in bool_set")
testing.expect(t, !has_flag(&cmd, "verbose"), "should not find missing flag")
}
@(test)
test_has_flag_empty_command :: proc(t: ^testing.T) {
cmd := Command {
name = "test",
}
testing.expect(t, !has_flag(&cmd, "anything"), "empty command should have no flags")
}
test_parse_args :: proc(
args: []string, args: []string,
) -> ( ) -> (
cmd: Command, cmd: Command,
@@ -179,6 +225,8 @@ test_parse_args_bare_command :: proc(t: ^testing.T) {
testing.expect_value(t, cmd.name, "list") testing.expect_value(t, cmd.name, "list")
testing.expect_value(t, len(cmd.args), 0) testing.expect_value(t, len(cmd.args), 0)
testing.expect_value(t, len(cmd.flags), 0) testing.expect_value(t, len(cmd.flags), 0)
testing.expect_value(t, len(cmd.bool_set), 0)
}
@(test) @(test)
test_parse_args_positional :: proc(t: ^testing.T) { test_parse_args_positional :: proc(t: ^testing.T) {
@@ -187,51 +235,49 @@ test_parse_args_positional :: proc(t: ^testing.T) {
testing.expect(t, ok, "should succeed") testing.expect(t, ok, "should succeed")
testing.expect(t, cmd.name == "backup") testing.expect(t, cmd.name == "backup")
testing.expect_value(t, len(cmd.args), 1) testing.expect(t, len(cmd.args) == 1)
testing.expect_value(t, cmd.args[0], "/project/.env") testing.expect(t, cmd.args[0] == "/project/.env")
} }
@(test) @(test)
test_parse_args_long_flag_with_value :: proc(t: ^testing.T) { test_parse_args_long_flag_with_value :: proc(t: ^testing.T) {
cmd, ok, _, _ := test_parse_args( cmd, ok, _, _ := test_parse_args([]string{"envr", "sync", "--config", "x.json"})
[]string{"envr", "sync", "--config-file", "x.json"}, testing.expect(t, ok, "should succeed")
)
testing.expect(t, ok, "should succeed")
if !ok do return if !ok do return
defer delete_command(&cmd) defer delete_command(&cmd)
testing.expect(t, cmd.flags["config"] == "x.json") testing.expect(t, cmd.flags["config"] == "x.json")
} }
@(test) @(test)
test_parse_args_short_flag_with_value :: proc(t: ^testing.T) { test_parse_args_short_flag_with_value :: proc(t: ^testing.T) {
cmd, ok, _, _ := test_parse_args([]string{"envr", "sync", "-c", "x.json"}) cmd, ok, _, _ := test_parse_args([]string{"envr", "sync", "-c", "x.json"})
testing.expect(t, ok, "should succeed") testing.expect(t, ok, "should succeed")
if !ok do return if !ok do return
defer delete_command(&cmd) defer delete_command(&cmd)
testing.expect(t, cmd.flags["c"] == "x.json") testing.expect(t, cmd.flags["c"] == "x.json")
} }
@(test) @(test)
test_parse_args_long_bool_flag :: proc(t: ^testing.T) { test_parse_args_long_bool_flag :: proc(t: ^testing.T) {
cmd, ok, _, _ := test_parse_args([]string{"envr", "init", "--force"}) cmd, ok, _, _ := test_parse_args([]string{"envr", "init", "--force"})
testing.expect(t, ok, "should succeed") testing.expect(t, ok, "should succeed")
if !ok do return if !ok do return
defer delete_command(&cmd) defer delete_command(&cmd)
testing.expect(t, cmd.bool_set["force"] == true) testing.expect(t, cmd.bool_set["force"] == true)
} }
@(test) @(test)
test_parse_args_short_bool_flag :: proc(t: ^testing.T) { test_parse_args_short_bool_flag :: proc(t: ^testing.T) {
cmd, ok, _, _ := test_parse_args([]string{"envr", "init", "-f"}) cmd, ok, _, _ := test_parse_args([]string{"envr", "version", "-l"})
testing.expect(t, ok, "should succeed") testing.expect(t, ok, "should succeed")
if !ok do return if !ok do return
defer delete_command(&cmd) defer delete_command(&cmd)
testing.expect(t, cmd.bool_set["l"] == true) testing.expect(t, cmd.bool_set["l"] == true)
} }
@(test) @(test)
test_parse_args_multiple_positionals :: proc(t: ^testing.T) { test_parse_args_multiple_positionals :: proc(t: ^testing.T) {
@@ -241,9 +287,9 @@ test_parse_args_multiple_positionals :: proc(t: ^testing.T) {
defer delete_command(&cmd) defer delete_command(&cmd)
testing.expect(t, len(cmd.args) == 2) testing.expect(t, len(cmd.args) == 2)
testing.expect_value(t, cmd.args[0], "a") testing.expect(t, cmd.args[0] == "a")
testing.expect_value(t, cmd.args[1], "b") testing.expect(t, cmd.args[1] == "b")
} }
@(test) @(test)
test_parse_args_mixed_flags_and_positionals :: proc(t: ^testing.T) { test_parse_args_mixed_flags_and_positionals :: proc(t: ^testing.T) {
@@ -253,9 +299,9 @@ test_parse_args_mixed_flags_and_positionals :: proc(t: ^testing.T) {
defer delete_command(&cmd) defer delete_command(&cmd)
testing.expect(t, cmd.bool_set["force"] == true) testing.expect(t, cmd.bool_set["force"] == true)
testing.expect_value(t, len(cmd.args), 1) testing.expect(t, len(cmd.args) == 1)
testing.expect_value(t, cmd.args[0], "/project/.env") testing.expect(t, cmd.args[0] == "/project/.env")
} }
@(test) @(test)
test_parse_args_no_args :: proc(t: ^testing.T) { test_parse_args_no_args :: proc(t: ^testing.T) {
@@ -267,77 +313,58 @@ test_parse_args_no_args :: proc(t: ^testing.T) {
@(test) @(test)
test_parse_args_flag_then_positional_then_flag :: proc(t: ^testing.T) { test_parse_args_flag_then_positional_then_flag :: proc(t: ^testing.T) {
cmd, ok, _, _ := test_parse_args([]string{"envr", "backup", "a.env", "--force", "--verbose"}) cmd, ok, _, _ := test_parse_args([]string{"envr", "backup", "a.env", "--force", "--verbose"})
defer delete_command(&cmd) defer delete_command(&cmd)
testing.expect(t, ok, "should succeed") testing.expect(t, ok, "should succeed")
testing.expect(t, cmd.bool_set["force"] == true) testing.expect(t, cmd.bool_set["force"] == true)
testing.expect_value(t, cmd.flags.output, Output_Format.JSON) testing.expect(t, cmd.bool_set["verbose"] == true)
testing.expect_value(t, len(cmd.args), 1) testing.expect(t, len(cmd.args) == 1)
testing.expect_value(t, cmd.args[0], "a.env") testing.expect(t, cmd.args[0] == "a.env")
} }
@(test) @(test)
test_parse_args_config_file_long_flag :: proc(t: ^testing.T) { test_parse_args_config_file_long_flag :: proc(t: ^testing.T) {
cmd, ok, _, _ := test_parse_args([]string{"envr", "list"}) cmd, ok, _, _ := test_parse_args(
[]string{"envr", "list", "--config-file", "/custom/config.json"},
)
testing.expect(t, ok, "should succeed")
if !ok do return
defer delete_command(&cmd)
testing.expect(
t,
cmd.config_path == "/custom/config.json",
"config_path should be set from --config-file",
)
}
@(test)
test_parse_args_config_file_short_flag :: proc(t: ^testing.T) {
cmd, ok, _, _ := test_parse_args([]string{"envr", "list", "-c", "/custom/config.json"})
testing.expect(t, ok, "should succeed")
if !ok do return
defer delete_command(&cmd)
testing.expect(
t,
cmd.config_path == "/custom/config.json",
"config_path should be set from -c",
)
}
@(test)
test_parse_args_config_file_defaults :: proc(t: ^testing.T) {
cmd, ok, _, _ := test_parse_args([]string{"envr", "list"})
testing.expect(t, ok, "should succeed") testing.expect(t, ok, "should succeed")
if !ok do return if !ok do return
defer delete_command(&cmd) defer delete_command(&cmd)
testing.expect(t, len(cmd.config_path) > 0, "config_path should default to non-empty path") testing.expect(t, len(cmd.config_path) > 0, "config_path should default to non-empty path")
testing.expect( testing.expect(
t, t,
strings.contains(cmd.config_path, ".envr"), strings.contains(cmd.config_path, ".envr"),
"default config_file should contain .envr dir, got %s", "default config_path should contain .envr dir, got %s",
) )
} }
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

@@ -15,15 +15,12 @@ cmd_backup :: proc(cmd: ^Command) {
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 {
return return
} }
db, db_ok := db_open(cmd.flags.config_file) db, db_ok := db_open(cmd.config_path)
if !db_ok { if !db_ok {
return return
} }

View File

@@ -4,46 +4,50 @@ 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 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.temp_allocator)
if cwd_err != nil { if cwd_err != nil {
fmt.wprintf(cmd.err, "Error getting current directory: %v\n", cwd_err, flush = false) fmt.wprintf(cmd.err, "Error getting current directory: %v\n", cwd_err, flush = false)
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.wprintf(cmd.err, "Error getting absolute path: %v\n", abs_err, flush = false)
return
}
abs_path = resolved
}
db, db_ok := db_open(cmd.config_path)
if !db_ok { 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) 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.wprintln(cmd.err, "Error scanning directory for .env files", flush = false)
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)
@@ -57,23 +61,13 @@ cmd_check :: proc(cmd: ^Command) {
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.wprintln(cmd.out, "No .env files found in the specified directory.", flush = false)
} else { } else {
fmt.wprintln( fmt.wprintln(cmd.out, "✓ All .env files in the directory are backed up.", flush = false)
cmd.out,
"✓ All .env files in the directory are backed up.",
flush = false,
)
} }
} else { } else {
fmt.wprintf( fmt.wprintf(cmd.out, "Found %d .env file(s) that are not backed up:\n", len(not_backed), flush = false)
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.wprintf(cmd.out, " %s\n", file, flush = false)
} }
fmt.wprintln(cmd.out, "\nRun 'envr sync' to back up these files.", flush = false) fmt.wprintln(cmd.out, "\nRun 'envr sync' to back up these files.", flush = false)
} }
} }

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

27
cmd_deps.odin Normal file
View File

@@ -0,0 +1,27 @@
package main
import "core:fmt"
import "core:os"
import "core:terminal"
// TODO: Improve table rendering
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 terminal.is_terminal(os.stdout) {
render_table(cmd.out, headers, rows[:])
} else {
render_json_rows(cmd.out, headers, rows[:])
fmt.wprint(cmd.out, "\n", flush = false)
}
}

View File

@@ -10,9 +10,10 @@ cmd_edit_config :: proc(cmd: ^Command) {
return return
} }
config_path := cmd.flags.config_file config_path := cmd.config_path
if !os.exists(config_path) { _, stat_err := os.stat(config_path, context.allocator)
if stat_err != nil {
fmt.wprintf( fmt.wprintf(
cmd.err, cmd.err,
"Config file does not exist at %s. Run 'envr init' first.\n", "Config file does not exist at %s. Run 'envr init' first.\n",
@@ -41,8 +42,6 @@ cmd_edit_config :: proc(cmd: ^Command) {
fmt.wprintf(cmd.err, "Error waiting for editor: %v\n", wait_err, flush = false) fmt.wprintf(cmd.err, "Error waiting for editor: %v\n", wait_err, flush = false)
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,15 +1,13 @@
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) fmt.wprintln(cmd.out, cmd.config_path, flush = false)
_, cfg_exists := load_config(config_file) _, cfg_exists := load_config(cmd.config_path)
if cfg_exists && !force { if cfg_exists && !force {
fmt.wprintln( fmt.wprintln(
cmd.out, cmd.out,
@@ -26,23 +24,15 @@ Run again with the --force flag if you want to reinitialize.`,
} }
if len(keys) == 0 { if len(keys) == 0 {
fmt.wprintln( fmt.wprintln(cmd.err, `No ssh-ed25519 keys found in ~/.ssh
cmd.err, Generate one with: ssh-keygen -t ed25519`, flush = false)
`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) defer delete(selected)
if result == .Cancel { if result == .Cancel {
fmt.wprintln( fmt.wprintln(cmd.out, "\x1b[2mCancelled.\x1b[0m", flush = false)
cmd.out,
ansi.CSI + ansi.FAINT + ansi.SGR + "Cancelled." + ANSI_RESET,
flush = false,
)
return return
} }
@@ -58,7 +48,7 @@ Generate one with: ssh-keygen -t ed25519`,
return return
} }
cfg := new_config(selected_paths[:], config_file) cfg := new_config(selected_paths[:], cmd.config_path)
if !save_config(cfg, force = force) { if !save_config(cfg, force = force) {
return return
} }
@@ -70,4 +60,3 @@ Generate one with: ssh-keygen -t ed25519`,
flush = false, flush = false,
) )
} }

View File

@@ -5,16 +5,17 @@ import "core:fmt"
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: Support --format flag
// TODO: Improve table rendering // 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(cmd.config_path)
if !db_ok { if !db_ok {
return return
} }
@@ -24,47 +25,36 @@ 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) render_table(cmd.out, headers, table_rows[:])
} else { } else {
// TODO: Should we instead print full entries here? // TODO: Should we instead print full entries here?
entries := make([dynamic]ListEntry, 0, len(rows), context.temp_allocator) entries: [dynamic]ListEntry
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[:], allocator = context.temp_allocator) 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.wprintf(cmd.err, "Error marshaling JSON: %v\n", marshal_err, flush = false)

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

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

View File

@@ -16,13 +16,20 @@ cmd_remove :: proc(cmd: ^Command) {
return return
} }
abs_path, abs_err := filepath.abs(path, context.temp_allocator) // TODO: Is this the best way to do it?
if abs_err != nil { abs_path: string
fmt.wprintf(cmd.err, "Error getting absolute path: %v\n", abs_err, flush = false) if filepath.is_abs(path) {
return abs_path = path
} else {
resolved, abs_err := filepath.abs(path)
if abs_err != nil {
fmt.wprintf(cmd.err, "Error getting absolute path: %v\n", abs_err, flush = false)
return
}
abs_path = resolved
} }
db, db_ok := db_open(cmd.flags.config_file) db, db_ok := db_open(cmd.config_path)
if !db_ok { if !db_ok {
return return
} }

View File

@@ -16,14 +16,21 @@ cmd_restore :: proc(cmd: ^Command) {
fmt.wprintln(cmd.err, "Error: No path provided", flush = false) fmt.wprintln(cmd.err, "Error: No path provided", flush = false)
return return
} }
abs_path, abs_err := filepath.abs(path, context.temp_allocator)
if abs_err != nil {
fmt.wprintf(cmd.err, "Error getting absolute path: %v\n", abs_err, flush = false)
return // TODO: Is this the right way to handle this?
abs_path: string
if filepath.is_abs(path) {
abs_path = path
} else {
resolved, abs_err := filepath.abs(path)
if abs_err != nil {
fmt.wprintf(cmd.err, "Error getting absolute path: %v\n", abs_err, flush = false)
return
}
abs_path = resolved
} }
db, db_ok := db_open(cmd.flags.config_file) db, db_ok := db_open(cmd.config_path)
if !db_ok { if !db_ok {
return return
} }
@@ -34,20 +41,15 @@ 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.wprintf(cmd.err, "Error writing file: %v\n", write_err, flush = false)
return return
} }
fmt.wprintf(cmd.out, "Restored %s\n", file.path, flush = false) fmt.wprintf(cmd.out, "Restored %s\n", file.Path, flush = false)
} }

View File

@@ -4,16 +4,15 @@ 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) db, db_ok := db_open(cmd.config_path)
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.wprintln(
cmd.err, cmd.err,
@@ -24,15 +23,9 @@ cmd_scan :: proc(cmd: ^Command) {
} }
// 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.wprintf(cmd.err, "Error scanning %s\n", dir, flush = false)
continue continue
@@ -72,11 +65,7 @@ cmd_scan :: proc(cmd: ^Command) {
selected, result := multi_select("Select .env files to backup:", files[:]) selected, result := multi_select("Select .env files to backup:", files[:])
defer delete(selected) defer delete(selected)
if result == .Cancel { if result == .Cancel {
fmt.wprintln( fmt.wprintln(cmd.out, "\x1b[2mCancelled.\x1b[0m", flush = false)
cmd.out,
ansi.CSI + ansi.FAINT + ansi.SGR + "Cancelled." + ANSI_RESET,
flush = false,
)
return return
} }
@@ -85,9 +74,7 @@ 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.wprintf(cmd.err, "Error reading %s\n", files[i], flush = false)
continue continue
@@ -102,23 +89,12 @@ cmd_scan :: proc(cmd: ^Command) {
if added_count > 0 { if added_count > 0 {
fmt.wprintf( fmt.wprintf(
cmd.out, cmd.out,
ansi.CSI + "\x1b[1;32mSuccessfully added %d file(s) to backup.\x1b[0m\n",
ansi.BOLD +
";" +
ansi.FG_GREEN +
ansi.SGR +
"Successfully added %d file(s) to backup." +
ANSI_RESET +
"\n",
added_count, added_count,
flush = false, flush = false,
) )
} else { } else {
fmt.wprintln( fmt.wprintln(cmd.out, "\x1b[2mNo files were added.\x1b[0m", flush = false)
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: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.
// TODO: Support --format -f flags
cmd_sync :: proc(cmd: ^Command) { cmd_sync :: proc(cmd: ^Command) {
db, db_ok := db_open(cmd.flags.config_file) db, db_ok := db_open(cmd.config_path)
if !db_ok { if !db_ok {
return return
} }
@@ -21,50 +24,68 @@ cmd_sync :: proc(cmd: ^Command) {
if !list_ok { if !list_ok {
return return
} }
defer delete(files)
results := make([]SyncEntry, len(files), context.temp_allocator) // TODO: Set sane default size
results: [dynamic]SyncEntry
defer delete(results)
for &file, i in files { for &file in files {
result, err := db_sync(&db, &file) old_path: string
old_path, _ = strings.clone(file.Path, context.temp_allocator)
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 { } else {
status = "error"
}
case .BackedUp in result:
status = "Backed Up"
case .Restored in result:
status = "Restored"
case .DirUpdated in result:
status = "Moved" status = "Moved"
} else { case:
status = "OK" status = "OK"
} }
results[i] = SyncEntry { if is_dir_updated {
path = file.path, if !db_delete(&db, old_path) {
status = status, return
}
} }
if db_update_required(result) {
if !db_insert(&db, file) {
return
}
}
path_str, _ := strings.clone(file.Path)
status_str, _ := strings.clone(status)
append(&results, SyncEntry{Path = path_str, Status = status_str})
} }
if cmd.flags.output == .Table { if terminal.is_terminal(os.stdout) {
t: table.Table headers := []string{"File", "Status"}
table.init(&t, context.temp_allocator, context.temp_allocator) table_rows := make([dynamic][]string, 0, len(results))
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 { for res in results {
table.row(&t, res.path, res.status) row_slice := make([]string, 2)
row_slice[0] = res.Path
row_slice[1] = res.Status
append(&table_rows, row_slice)
} }
table.write_decorated_table(cmd.out, &t, decorations, ansi_aware_width) render_table(cmd.out, headers, table_rows[:])
} else { } else {
data, marshal_err := json.marshal(results[:], allocator = context.temp_allocator) data, marshal_err := json.marshal(results[:])
if marshal_err != nil { if marshal_err != nil {
fmt.wprintf(cmd.err, "Error marshaling JSON: %v\n", marshal_err, flush = false) fmt.wprintf(cmd.err, "Error marshaling JSON: %v\n", marshal_err, flush = false)
return return
@@ -73,23 +94,3 @@ cmd_sync :: proc(cmd: ^Command) {
} }
} }
sync_error_message :: proc(e: SyncError) -> string {
switch e {
case .None:
return ""
case .DirMissing:
return "directory missing"
case .MultipleDirs:
return "multiple directories found"
case .GitRootFailed:
return "failed to find git roots"
case .WriteFailed:
return "failed to write file"
case .ReadFailed:
return "failed to read file"
case .DbFailed:
return "failed to update database"
}
return "unknown error"
}

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,6 +1,5 @@
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"
@@ -9,40 +8,21 @@ import "core:strings"
import "findr" 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"`,
if read_err != nil { config_path: string `json:"-"`,
fmt.eprintln("No config file found. Please run `envr init` to generate one.")
return Config{}, false
}
cfg: Config
err := json.unmarshal(data, &cfg, .JSON5, allocator)
if err != nil {
fmt.eprintf("Error parsing config: %v\n", err)
return Config{}, false
}
cfg.config_path = config_path
return cfg, true
} }
default_config_path :: proc(home: string, allocator := context.allocator) -> string { default_config_path :: proc(home: string, allocator := context.allocator) -> string {
@@ -53,116 +33,71 @@ default_config_path :: proc(home: string, allocator := context.allocator) -> str
return path return path
} }
delete_config :: proc(cfg: ^Config, allocator := context.allocator) { load_config :: proc(config_path: string) -> (Config, bool) {
for key in cfg.keys { data, read_err := os.read_entire_file_from_path(config_path, context.allocator)
delete(key.private, allocator) if read_err != nil {
delete(key.public, allocator) fmt.println("No config file found. Please run `envr init` to generate one.")
return Config{}, false
} }
delete(cfg.keys) defer delete(data)
delete(cfg.scan_config.matcher, allocator) cfg: Config
// TODO: use json 5
for exclude in cfg.scan_config.exclude { err := json.unmarshal(data, &cfg)
delete(exclude, allocator) if err != nil {
fmt.printf("Error parsing config: %v\n", err)
return Config{}, false
} }
delete(cfg.scan_config.exclude) cfg.config_path = config_path
for include in cfg.scan_config.include { return cfg, true
delete(include, allocator)
}
delete(cfg.scan_config.include)
} }
save_config :: proc(cfg: Config, force: bool = false) -> bool { delete_config :: proc(cfg: ^Config) {
config_dir := envr_dir(cfg.config_path) for key in cfg.Keys {
delete(key.Private)
if !os.exists(config_dir) { delete(key.Public)
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
}
} }
delete(cfg.Keys)
if os.exists(cfg.config_path) && !force { delete(cfg.ScanConfig.Matcher)
info, stat_err := os.stat(cfg.config_path, context.temp_allocator)
if stat_err == nil { for exclude in cfg.ScanConfig.Exclude {
defer os.file_info_delete(info, context.temp_allocator) delete(exclude)
if info.size > 0 {
fmt.eprintln("Config file already exists. Run again with --force to reinitialize.")
return false
}
}
} }
delete(cfg.ScanConfig.Exclude)
data, marshal_err := json.marshal( for include in cfg.ScanConfig.Include {
cfg, delete(include)
{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
} }
delete(cfg.ScanConfig.Include)
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() envr_dir :: proc(config_path: string) -> string {
new_config :: proc( return filepath.dir(config_path)
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 data_path :: proc(config_path: string) -> string {
// configs vs user created configs. path, _ := filepath.join([]string{envr_dir(config_path), "data.envr"})
exclude := make([dynamic]string, 0, 4) return path
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 +128,100 @@ find_ssh_private_keys :: proc() -> (keys: [dynamic]string, ok: bool) {
return return
} }
find_git_roots :: proc( // Caller is responsible for calling delete_config()
cfg: Config, new_config :: proc(
allocator := context.temp_allocator, private_key_paths: []string,
) -> ( cfg_path: string = "~/.envr/config.json",
roots: [dynamic]string, ) -> Config {
ok: bool, keys := make([dynamic]SshKeyPair, 0, len(private_key_paths))
) { for priv in private_key_paths {
paths := search_paths(cfg, allocator) // TODO: Is this bad?
// TODO: Pass allocator to findr priv_key := strings.clone(priv)
pub, _ := strings.concatenate([]string{priv_key, ".pub"})
append(&keys, SshKeyPair{Private = priv_key, Public = pub})
}
exclude := make([dynamic]string, 0, 4)
append(&exclude, strings.clone("*\\.envrc"))
append(&exclude, strings.clone("\\.local/"))
append(&exclude, strings.clone("node_modules"))
append(&exclude, strings.clone("vendor"))
include := make([dynamic]string, 0, 1)
append(&include, strings.clone("~"))
scan_cfg := ScanConfig {
Matcher = strings.clone("\\.env"),
Exclude = exclude,
Include = include,
}
return Config{Keys = keys, ScanConfig = scan_cfg, config_path = cfg_path}
}
save_config :: proc(cfg: Config, force: bool = false) -> bool {
config_dir := envr_dir(cfg.config_path)
if !os.exists(config_dir) {
mkdir_err := os.make_directory(config_dir)
if mkdir_err != nil {
fmt.printf("Error creating %s directory: %v\n", config_dir, mkdir_err)
return false
}
}
if os.exists(cfg.config_path) && !force {
info, stat_err := os.stat(cfg.config_path, context.allocator)
if stat_err == nil {
defer os.file_info_delete(info, context.allocator)
if info.size > 0 {
fmt.println("Config file already exists. Run again with --force to reinitialize.")
return false
}
}
}
data, marshal_err := json.marshal(cfg, {pretty = true, use_spaces = true, spaces = 2})
if marshal_err != nil {
fmt.printf("Error marshaling config: %v\n", marshal_err)
return false
}
defer delete(data)
write_err := os.write_entire_file(cfg.config_path, data)
if write_err != nil {
fmt.printf("Error writing config: %v\n", write_err)
return false
}
return true
}
search_paths :: proc(cfg: Config) -> (paths: [dynamic]string) {
// TODO: Is this okay?
// TODO: handle error
home, _ := os.user_home_dir(context.temp_allocator)
for include in cfg.ScanConfig.Include {
// TODO: Do we need to manually expand ~/ in odin?
expanded, _ := strings.replace(include, "~", home, 1)
if filepath.is_abs(expanded) {
append(&paths, expanded)
} else {
defer delete(expanded)
resolved, err := filepath.abs(expanded)
if err == nil {
append(&paths, resolved)
}
}
}
return
}
find_git_roots :: proc(cfg: Config) -> (roots: [dynamic]string, ok: bool) {
paths := search_paths(cfg)
findr.find_repos(paths[:], &roots, os.get_processor_core_count()) findr.find_repos(paths[:], &roots, os.get_processor_core_count())
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,4 +1,3 @@
#+test
package main package main
import "core:fmt" import "core:fmt"
@@ -16,9 +15,13 @@ test_new_config_single_key :: proc(t: ^testing.T) {
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)
@@ -27,9 +30,9 @@ test_new_config_multiple_keys :: proc(t: ^testing.T) {
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)
@@ -38,7 +41,7 @@ test_new_config_empty_keys :: proc(t: ^testing.T) {
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)
@@ -47,10 +50,10 @@ test_new_config_scan_defaults :: proc(t: ^testing.T) {
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)
@@ -61,17 +64,18 @@ test_new_config_exclude_patterns :: proc(t: ^testing.T) {
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)
test_save_load_config_roundtrip :: proc(t: ^testing.T) { test_save_load_config_roundtrip :: proc(t: ^testing.T) {
base := test_temp_dir(t, "envr-test-cfg-rt-*") base := fmt.tprintf("/tmp/envr-test-cfg-rt-%d", os.get_pid())
os.mkdir_all(base)
defer os.remove_all(base) defer os.remove_all(base)
cfgPath, err := filepath.join([]string{base, "config.json"}, context.temp_allocator) cfgPath, err := filepath.join([]string{base, "config.json"}, context.temp_allocator)
testing.expect_value(t, err, nil) testing.expect(t, err == nil, "cfgPath should build successfully")
cfg := new_config([]string{"/home/user/.ssh/id_ed25519"}, cfgPath) cfg := new_config([]string{"/home/user/.ssh/id_ed25519"}, cfgPath)
defer delete_config(&cfg) defer delete_config(&cfg)
@@ -83,13 +87,13 @@ test_save_load_config_roundtrip :: proc(t: ^testing.T) {
if !ok do return if !ok do return
defer delete_config(&loaded) defer delete_config(&loaded)
testing.expect_value(t, len(loaded.keys), 1) testing.expect(t, len(loaded.Keys) == 1, "should have 1 key")
testing.expect_value(t, loaded.keys[0].private, "/home/user/.ssh/id_ed25519") testing.expect(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(t, loaded.Keys[0].Public == "/home/user/.ssh/id_ed25519.pub")
testing.expect_value(t, loaded.scan_config.matcher, "\\.env") testing.expect(t, loaded.ScanConfig.Matcher == "\\.env")
testing.expect_value(t, len(loaded.scan_config.exclude), 4) testing.expect(t, len(loaded.ScanConfig.Exclude) == 4)
testing.expect_value(t, len(loaded.scan_config.include), 1) testing.expect(t, len(loaded.ScanConfig.Include) == 1)
testing.expect_value(t, loaded.scan_config.include[0], "~") testing.expect(t, loaded.ScanConfig.Include[0] == "~")
} }
@(test) @(test)
@@ -100,11 +104,12 @@ test_load_config_missing :: proc(t: ^testing.T) {
@(test) @(test)
test_save_config_no_clobber :: proc(t: ^testing.T) { test_save_config_no_clobber :: proc(t: ^testing.T) {
base := test_temp_dir(t, "envr-test-cfg-noclobber-*") base := fmt.tprintf("/tmp/envr-test-cfg-noclobber-%d", os.get_pid())
os.mkdir_all(base)
defer os.remove_all(base) defer os.remove_all(base)
cfgPath, err := filepath.join([]string{base, "config.json"}, context.temp_allocator) cfgPath, err := filepath.join([]string{base, "config.json"}, context.temp_allocator)
testing.expect_value(t, err, nil) testing.expect(t, err == nil, "cfgPath should build successfully")
cfg := new_config([]string{"/home/user/.ssh/key1"}, cfgPath) cfg := new_config([]string{"/home/user/.ssh/key1"}, cfgPath)
defer delete_config(&cfg) defer delete_config(&cfg)
@@ -117,11 +122,12 @@ test_save_config_no_clobber :: proc(t: ^testing.T) {
@(test) @(test)
test_save_config_force_overwrites :: proc(t: ^testing.T) { test_save_config_force_overwrites :: proc(t: ^testing.T) {
base := test_temp_dir(t, "envr-test-cfg-force-*") base := fmt.tprintf("/tmp/envr-test-cfg-force-%d", os.get_pid())
os.mkdir_all(base)
defer os.remove_all(base) defer os.remove_all(base)
cfgPath, err := filepath.join([]string{base, "config.json"}, context.temp_allocator) cfgPath, err := filepath.join([]string{base, "config.json"}, context.temp_allocator)
testing.expect_value(t, err, nil) testing.expect(t, err == nil, "cfgPath should build successfully")
cfg := new_config([]string{"/home/user/.ssh/key1"}, cfgPath) cfg := new_config([]string{"/home/user/.ssh/key1"}, cfgPath)
defer delete_config(&cfg) defer delete_config(&cfg)
@@ -136,8 +142,12 @@ test_save_config_force_overwrites :: proc(t: ^testing.T) {
if !ok do return if !ok do return
defer delete_config(&loaded) defer delete_config(&loaded)
testing.expect_value(t, len(loaded.keys), 1) testing.expect(t, len(loaded.Keys) == 1, "should have 1 key")
testing.expect_value(t, loaded.keys[0].private, "/home/user/.ssh/key2") testing.expect(
t,
loaded.Keys[0].Private == "/home/user/.ssh/key2",
"should be the overwritten key",
)
} }
@(test) @(test)
@@ -175,14 +185,18 @@ test_search_paths_expands_tilde :: proc(t: ^testing.T) {
os.set_env("HOME", "/tmp/envr-fake-home-search") os.set_env("HOME", "/tmp/envr-fake-home-search")
cfg := Config { cfg := Config {
scan_config = ScanConfig{include = make([dynamic]string, 0, 1)}, ScanConfig = ScanConfig{Include = make([dynamic]string, 0, 1)},
} }
append(&cfg.scan_config.include, "~") defer delete(cfg.ScanConfig.Include)
defer delete(cfg.scan_config.include) append(&cfg.ScanConfig.Include, "~")
paths := search_paths(cfg, context.temp_allocator) paths := search_paths(cfg)
defer delete(paths)
for path in paths {
defer delete(path)
}
testing.expect_value(t, len(paths), 1) testing.expect(t, len(paths) == 1, "should have 1 path")
if len(paths) > 0 { if len(paths) > 0 {
testing.expectf( testing.expectf(
t, t,

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
}
}
ok = true
return
} }
// TODO: Optimize performance
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,13 @@ 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(
// TODO: Should this be a loop? t,
len(decrypted) == len(original),
fmt.tprintf("expected %d bytes, got %d", len(original), len(decrypted)),
)
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 +54,16 @@ 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(
testing.expect_value(t, decrypted2[i], original[i]) t,
decrypted1[i] == original[i],
fmt.tprintf("key1: byte mismatch at %d", i),
)
testing.expect(
t,
decrypted2[i] == original[i],
fmt.tprintf("key2: byte mismatch at %d", i),
)
} }
} }
@@ -85,7 +94,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)
@@ -102,9 +111,8 @@ test_recipient_can_decrypt_senders_data :: proc(t: ^testing.T) {
testing.expect(t, dec_ok, "second recipient should decrypt without the sender key present") testing.expect(t, dec_ok, "second recipient should decrypt without the sender key present")
defer delete(decrypted) defer delete(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 %d", i))
} }
} }
@@ -118,9 +126,9 @@ 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")
} }

876
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_deserialize_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,27 +150,27 @@ 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 mem_db: ^rawptr
rc := sqlite.open(":memory:", &mem_db) 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)) n := i64(len(plaintext))
buf := sqlite.malloc64(n) buf := sqlite.malloc64(n)
@@ -188,24 +178,31 @@ test_decrypt_then_deserialize_sqlite :: proc(t: ^testing.T) {
if buf == nil do return if buf == nil do return
copy(buf[:len(plaintext)], plaintext) copy(buf[:len(plaintext)], plaintext)
rc = sqlite.deserialize(mem_db, "main", buf, n, n, {.FREEONCLOSE, .RESIZEABLE}) rc = sqlite.deserialize(
testing.expect_value(t, rc, sqlite.OK) mem_db,
"main",
buf,
n,
n,
sqlite.DESERIALIZE_FREEONCLOSE | sqlite.DESERIALIZE_RESIZEABLE,
)
testing.expect(t, rc == sqlite.OK, "deserialize should succeed")
if rc != sqlite.OK { if rc != sqlite.OK {
sqlite.free(buf) sqlite.free(buf)
return return
} }
sql: cstring = "SELECT path FROM envr_env_files" sql: cstring = "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, 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 := 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 +212,7 @@ 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 +222,18 @@ 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"})
defer delete(data_path)
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 +247,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 +288,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 +309,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,8 +1,5 @@
#+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:path/filepath"
@@ -11,192 +8,225 @@ 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: cstring = "CREATE TABLE IF NOT EXISTS envr_env_files (path TEXT PRIMARY KEY NOT NULL, remotes TEXT, sha256 TEXT NOT NULL, contents TEXT NOT NULL)"
rc = sqlite.db_exec(db, create_sql, nil, nil, nil)
if rc != sqlite.OK {
sqlite.db_close(db)
return Db{}, false
}
return Db{db = db}, true
}
make_test_env_file :: proc(path, sha, contents: string, remotes: []string = {}) -> EnvFile { 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" path := "/project/.env"
sha := "abc123" sha := "abc123"
contents := "SECRET=value" contents := "SECRET=value"
f := make_test_env_file(path, sha, contents, []string{"git@github.com:user/repo.git"}) f := make_test_env_file(path, sha, contents, []string{"git@github.com:user/repo.git"})
defer delete(f.remotes) defer delete(f.Remotes)
testing.expect(t, db_insert(&db, f), "insert should succeed") testing.expect(t, db_insert(&d, f), "insert should succeed")
fetched, fetch_ok := db_fetch(&db, "/project/.env") fetched, fetch_ok := db_fetch(&d, "/project/.env")
// defer delete_envfile(&fetched) 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
testing.expect_value(t, fetched.path, path) testing.expect_value(t, fetched.Path, path)
testing.expect_value(t, fetched.sha256, sha) testing.expect_value(t, fetched.Sha256, sha)
testing.expect_value(t, fetched.contents, contents) testing.expect_value(t, fetched.contents, contents)
testing.expect_value(t, len(fetched.remotes), 1) testing.expect_value(t, len(fetched.Remotes), 1)
testing.expect_value(t, fetched.remotes[0], "git@github.com:user/repo.git") testing.expect_value(t, fetched.Remotes[0], "git@github.com:user/repo.git")
} }
@(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)
for &result in results {
defer delete_envfile(&result)
}
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_envfile(&fetched)
testing.expect_value(t, fetched.contents, "KEY=new") testing.expect_value(t, fetched.contents, "KEY=new")
testing.expect_value(t, fetched.sha256, "sha2") testing.expect_value(t, fetched.Sha256, "sha2")
} }
@(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) 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) defer delete(f2.Remotes)
f3 := make_test_env_file("/proj3/.env", "sha3", "C=3") f3 := make_test_env_file("/proj3/.env", "sha3", "C=3")
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)
defer {
for &result in results {
delete_envfile(&result)
}
}
testing.expect_value(t, len(results), 3) testing.expect_value(t, len(results), 3)
} }
@(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_serialize :: 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 sz: i64
data := sqlite.serialize(db.conn, "main", &sz, {}) data := sqlite.serialize(d.db, "main", &sz, 0)
testing.expect(t, data != nil, "serialize should return non-nil") testing.expect(t, data != nil, "serialize should return non-nil")
if data == nil do return if data == nil do return
defer sqlite.free(data) defer sqlite.free(data)
@@ -204,13 +234,44 @@ test_db_serialize :: proc(t: ^testing.T) {
testing.expect(t, sz > 0, "serialized size should be > 0") testing.expect(t, sz > 0, "serialized size should be > 0")
} }
@(test)
test_db_update_required_noop :: proc(t: ^testing.T) {
testing.expect(t, !db_update_required({}), "Noop should not require update")
}
@(test)
test_db_update_required_backed_up :: proc(t: ^testing.T) {
testing.expect(t, db_update_required({.BackedUp}), "BackedUp should require update")
}
@(test)
test_db_update_required_dir_updated :: proc(t: ^testing.T) {
testing.expect(t, db_update_required({.DirUpdated}), "DirUpdated should require update")
}
@(test)
test_db_update_required_restored :: proc(t: ^testing.T) {
testing.expect(t, !db_update_required({.Restored}), "Restored alone should not require update")
}
@(test)
test_db_update_required_error :: proc(t: ^testing.T) {
testing.expect(t, !db_update_required({.Error}), "Error alone should not require update")
}
@(test)
test_db_update_required_combined :: proc(t: ^testing.T) {
combined := SyncFlag{.DirUpdated, .Restored}
testing.expect(t, db_update_required(combined), "DirUpdated|Restored should require update")
}
@(test) @(test)
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,107 +312,41 @@ 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)
test_get_git_remotes_single :: proc(t: ^testing.T) { test_make_temp_path_format :: proc(t: ^testing.T) {
base := test_temp_dir(t, "envr-test-remotes-*") p := make_temp_path()
defer os.remove_all(base) testing.expect(t, strings.has_suffix(p, ".db"), "should end with .db")
testing.expect(t, strings.contains(p, fmt.tprintf("%d", os.get_pid())), "should contain PID")
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)
test_new_env_file :: proc(t: ^testing.T) { test_new_env_file :: proc(t: ^testing.T) {
base := test_temp_dir(t, "envr-test-envfile-*") base := fmt.tprintf("/tmp/envr-test-envfile-%d", os.get_pid())
os.mkdir_all(base)
defer os.remove_all(base) defer os.remove_all(base)
env_path := fmt.tprintf("%s/.env", base) env_path := fmt.tprintf("%s/.env", base)
err := os.write_entire_file(env_path, "SECRET=value\n") err := os.write_entire_file(env_path, "SECRET=value\n")
testing.expect_value(t, err, nil) testing.expect(t, err == nil, ".env file should exists")
file, ok := new_env_file(env_path) file, ok := new_env_file(env_path)
testing.expect(t, ok, "new_env_file should succeed") testing.expect(t, ok, "new_env_file should succeed")
if !ok do return if !ok do return
defer delete(file.contents) defer delete(file.Remotes)
defer delete(file.remotes) defer delete(file.Sha256)
defer delete(file.sha256) defer delete(file.Path)
defer delete(file.path)
testing.expect(t, filepath.is_abs(file.path), "path should be absolute") testing.expect(t, filepath.is_abs(file.Path), "path should be absolute")
testing.expect(t, strings.has_suffix(file.path, "/.env"), "path should end with /.env") testing.expect(t, strings.has_suffix(file.Path, "/.env"), "path should end with /.env")
testing.expect_value(t, file.contents, "SECRET=value\n") testing.expect(t, file.contents == "SECRET=value\n", "contents mismatch")
testing.expect_value(t, len(file.sha256), 64) testing.expect(t, len(file.Sha256) == 64, "sha256 should be 64 hex chars")
} }
@(test) @(test)
@@ -361,201 +356,45 @@ test_new_env_file_missing :: proc(t: ^testing.T) {
} }
@(test) @(test)
test_closing_db_has_no_leaks :: proc(t: ^testing.T) { test_env_file_backup :: proc(t: ^testing.T) {
base := test_temp_dir(t, "envr-test-leak-*") base := fmt.tprintf("/tmp/envr-test-backup-%d", os.get_pid())
defer os.remove_all(base) os.mkdir_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) defer os.remove_all(base)
env_path := fmt.tprintf("%s/.env", base) env_path := fmt.tprintf("%s/.env", base)
content := "KEY=value\n" err := os.write_entire_file(env_path, "KEY=12345\n")
write_err := os.write_entire_file(env_path, transmute([]u8)content) testing.expect(t, err == nil, ".env file should exist")
testing.expect_value(t, write_err, nil)
digest := hash.hash_bytes( f := EnvFile {
hash.Algorithm.SHA256, Path = env_path,
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")
} }
defer delete(f.contents)
defer delete(f.Sha256)
testing.expect(t, env_file_backup(&f), "backup should succeed")
testing.expect_value(t, f.contents, "KEY=12345\n")
testing.expect_value(t, len(f.Sha256), 64)
} }
@(test) @(test)
test_db_sync_dir_missing :: proc(t: ^testing.T) { test_env_file_backup_missing :: proc(t: ^testing.T) {
db, ok := db_init() f := EnvFile {
testing.expect(t, ok, "failed to create test db") Path = "/tmp/envr-nonexistent-backup/.env",
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")
} }
testing.expect(t, !env_file_backup(&f), "missing file should return false")
}
@(test)
test_update_dir :: proc(t: ^testing.T) {
f := EnvFile {
Path = "/old/project/.env",
Dir = "/old/project",
Remotes = make([dynamic]string, 0),
}
defer delete_envfile(&f)
update_dir(&f, "/new/location")
testing.expect_value(t, f.Dir, "/new/location")
testing.expect_value(t, f.Path, "/new/location/.env")
} }

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.

47
features.odin Normal file
View File

@@ -0,0 +1,47 @@
package main
import "base:runtime"
import "core:mem"
import "core:os"
import "core:strings"
Feature :: enum {
Git,
}
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}
}
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

@@ -21,7 +21,9 @@ test_basic_gitignored :: proc(t: ^testing.T) {
create_file(env, "repo/secrets.env") create_file(env, "repo/secrets.env")
create_file(env, "repo/normal.txt") create_file(env, "repo/normal.txt")
assert_output(t, env, nil, {}, {"repo/.env", "repo/secrets.env"}) assert_output(t, env, nil, {}, {
"repo/.env", "repo/secrets.env",
})
} }
@(test) @(test)
@@ -47,7 +49,9 @@ test_negation_pattern :: proc(t: ^testing.T) {
create_file(env, "repo/secrets.env") create_file(env, "repo/secrets.env")
create_file(env, "repo/prod.env") create_file(env, "repo/prod.env")
assert_output(t, env, nil, {}, {"repo/.env", "repo/secrets.env"}) assert_output(t, env, nil, {}, {
"repo/.env", "repo/secrets.env",
})
} }
@(test) @(test)
@@ -63,7 +67,9 @@ test_multiple_repos :: proc(t: ^testing.T) {
create_file(env, "repo2/.gitignore", "*.key\n") create_file(env, "repo2/.gitignore", "*.key\n")
create_file(env, "repo2/secret.key") create_file(env, "repo2/secret.key")
assert_output(t, env, nil, {}, {"repo1/a.env", "repo2/secret.key"}) assert_output(t, env, nil, {}, {
"repo1/a.env", "repo2/secret.key",
})
} }
@(test) @(test)
@@ -79,7 +85,9 @@ test_nested_repos :: proc(t: ^testing.T) {
create_file(env, "parent/child/.gitignore", "*.key\n") create_file(env, "parent/child/.gitignore", "*.key\n")
create_file(env, "parent/child/api.key") create_file(env, "parent/child/api.key")
assert_output(t, env, nil, {}, {"parent/top.env", "parent/child/api.key"}) assert_output(t, env, nil, {}, {
"parent/top.env", "parent/child/api.key",
})
} }
@(test) @(test)
@@ -94,7 +102,9 @@ test_nested_gitignore_read :: proc(t: ^testing.T) {
create_file(env, "repo/sub/secret.txt") create_file(env, "repo/sub/secret.txt")
create_file(env, "repo/sub/.env") create_file(env, "repo/sub/.env")
assert_output(t, env, nil, {}, {"repo/sub/secret.txt", "repo/sub/.env"}) assert_output(t, env, nil, {}, {
"repo/sub/secret.txt", "repo/sub/.env",
})
} }
@(test) @(test)
@@ -109,7 +119,9 @@ test_nested_gitignore_negation :: proc(t: ^testing.T) {
create_file(env, "repo/sub/important.log") create_file(env, "repo/sub/important.log")
create_file(env, "repo/sub/debug.log") create_file(env, "repo/sub/debug.log")
assert_output(t, env, nil, {}, {"repo/sub/debug.log"}) assert_output(t, env, nil, {}, {
"repo/sub/debug.log",
})
} }
@(test) @(test)
@@ -124,7 +136,9 @@ test_multisegment_pattern :: proc(t: ^testing.T) {
create_file(env, "repo/build/other.txt") create_file(env, "repo/build/other.txt")
create_file(env, "repo/output.txt") create_file(env, "repo/output.txt")
assert_output(t, env, nil, {}, {"repo/build/output.txt"}) assert_output(t, env, nil, {}, {
"repo/build/output.txt",
})
} }
@(test) @(test)
@@ -186,7 +200,7 @@ test_multiple_search_dirs :: proc(t: ^testing.T) {
stripped := r stripped := r
if strings.has_prefix(stripped, env.temp_dir) { if strings.has_prefix(stripped, env.temp_dir) {
stripped = stripped[len(env.temp_dir):] stripped = stripped[len(env.temp_dir):]
if len(stripped) > 0 && stripped[0] == os.Path_Separator { if len(stripped) > 0 && stripped[0] == '/' {
stripped = stripped[1:] stripped = stripped[1:]
} }
} }
@@ -220,7 +234,9 @@ test_ignored_dir_descended :: proc(t: ^testing.T) {
create_file(env, "repo/secrets/api.key") create_file(env, "repo/secrets/api.key")
// Ignored dir's contents are emitted AND descended into // Ignored dir's contents are emitted AND descended into
assert_output(t, env, nil, {}, {"repo/secrets/", "repo/secrets/.env", "repo/secrets/api.key"}) assert_output(t, env, nil, {}, {
"repo/secrets/", "repo/secrets/.env", "repo/secrets/api.key",
})
} }
@(test) @(test)
@@ -235,13 +251,10 @@ test_nested_ignored_dir :: proc(t: ^testing.T) {
create_file(env, "repo/build/output.txt") create_file(env, "repo/build/output.txt")
create_file(env, "repo/build/sub/deep.env") create_file(env, "repo/build/sub/deep.env")
assert_output( assert_output(t, env, nil, {}, {
t, "repo/build/", "repo/build/output.txt",
env, "repo/build/sub/", "repo/build/sub/deep.env",
nil, })
{},
{"repo/build/", "repo/build/output.txt", "repo/build/sub/", "repo/build/sub/deep.env"},
)
} }
// ============================================================================ // ============================================================================
@@ -259,7 +272,10 @@ test_excludes_prune_dirs :: proc(t: ^testing.T) {
create_dir(env, "repo/vendor") create_dir(env, "repo/vendor")
create_file(env, "repo/vendor/lib.env") create_file(env, "repo/vendor/lib.env")
assert_output(t, env, nil, {excludes = {"vendor"}}, {"repo/.env"}) assert_output(t, env, nil,
{excludes = {"vendor"}},
{"repo/.env"},
)
} }
@(test) @(test)
@@ -273,7 +289,10 @@ test_pattern_filters_results :: proc(t: ^testing.T) {
create_file(env, "repo/secrets.env") create_file(env, "repo/secrets.env")
create_file(env, "repo/master.key") create_file(env, "repo/master.key")
assert_output(t, env, nil, {pattern = "\\.env$"}, {"repo/.env", "repo/secrets.env"}) assert_output(t, env, nil,
{pattern = "\\.env$"},
{"repo/.env", "repo/secrets.env"},
)
} }
// ============================================================================ // ============================================================================
@@ -294,6 +313,8 @@ test_fifo_emitted :: proc(t: ^testing.T) {
defer delete(cpath) defer delete(cpath)
linux.mknod(cpath, linux.S_IFIFO | linux.Mode{.IRUSR, .IWUSR}, 0) linux.mknod(cpath, linux.S_IFIFO | linux.Mode{.IRUSR, .IWUSR}, 0)
assert_output(t, env, nil, {pattern = "\\.fifo$"}, {"repo/test.fifo"}) assert_output(t, env, nil,
{pattern = "\\.fifo$"},
{"repo/test.fifo"},
)
} }

View File

@@ -26,7 +26,7 @@ find_repos :: proc(roots: []string, results: ^[dynamic]string, thread_count: int
pool.threads = make([]^thread.Thread, thread_count) pool.threads = make([]^thread.Thread, thread_count)
for root in roots { for root in roots {
root_clone := strings.clone(root) root_clone, _ := strings.clone(root)
append(&pool.queue, root_clone) append(&pool.queue, root_clone)
sync.atomic_sema_post(&pool.queue_sema) sync.atomic_sema_post(&pool.queue_sema)
} }
@@ -97,7 +97,7 @@ process_repo_dir :: proc(pool: ^RepoPool, dir_path: string) {
defer linux.close(fd) defer linux.close(fd)
if has_git_dir(fd) { if has_git_dir(fd) {
cloned := strings.clone(dir_path) cloned, _ := strings.clone(dir_path)
sync.mutex_lock(&pool.results_lock) sync.mutex_lock(&pool.results_lock)
append(pool.results, cloned) append(pool.results, cloned)
sync.mutex_unlock(&pool.results_lock) sync.mutex_unlock(&pool.results_lock)

View File

@@ -37,7 +37,7 @@ create_file :: proc(env: TestEnv, path: string, content: string = "") {
full := join_path(env.temp_dir, path) full := join_path(env.temp_dir, path)
defer delete(full) defer delete(full)
dir_end := strings.last_index(full, os.Path_Separator_String) dir_end := strings.last_index(full, "/")
if dir_end >= 0 { if dir_end >= 0 {
dir_path := full[:dir_end] dir_path := full[:dir_end]
os.mkdir_all(dir_path, os.Permissions_Default_Directory) os.mkdir_all(dir_path, os.Permissions_Default_Directory)
@@ -105,7 +105,12 @@ assert_output :: proc(
} }
} }
assert_output_empty :: proc(t: ^testing.T, env: TestEnv, args: []string, opts: WalkOptions) { assert_output_empty :: proc(
t: ^testing.T,
env: TestEnv,
args: []string,
opts: WalkOptions,
) {
results := collect_results(env, args, opts) results := collect_results(env, args, opts)
defer { defer {
for r in results {delete(r)} for r in results {delete(r)}
@@ -134,10 +139,10 @@ collect_results :: proc(env: TestEnv, args: []string, opts: WalkOptions) -> [dyn
r := results[i] r := results[i]
if strings.has_prefix(r, env.temp_dir) { if strings.has_prefix(r, env.temp_dir) {
stripped := r[len(env.temp_dir):] stripped := r[len(env.temp_dir):]
if len(stripped) > 0 && stripped[0] == os.Path_Separator { if len(stripped) > 0 && stripped[0] == '/' {
stripped = stripped[1:] stripped = stripped[1:]
} }
new_r := strings.clone(stripped) new_r, _ := strings.clone(stripped)
delete(r) delete(r)
results[i] = new_r results[i] = new_r
} }
@@ -145,4 +150,3 @@ collect_results :: proc(env: TestEnv, args: []string, opts: WalkOptions) -> [dyn
return results return results
} }

View File

@@ -1,6 +1,5 @@
package findr package findr
import "core:bytes"
import "core:fmt" import "core:fmt"
import "core:os" import "core:os"
import "core:strings" import "core:strings"
@@ -55,26 +54,19 @@ Collector_Data :: struct {
collect_worker :: proc(t: ^thread.Thread) { collect_worker :: proc(t: ^thread.Thread) {
data := cast(^Collector_Data)t.data data := cast(^Collector_Data)t.data
for { for {
batch := chan.recv(data.ch) or_break batch, ok := chan.recv(data.ch)
defer delete(batch) if !ok do break
start := 0 start := 0
for { for i in 0 ..< len(batch) {
remaining: []u8 if batch[i] == '\n' {
#no_bounds_check {remaining = batch[start:]} if i > start {
s, _ := strings.clone(string(batch[start:i]))
idx := bytes.index_byte(remaining, '\n') append(data.results, s)
if idx < 0 do break }
start = i + 1
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
} }
delete(batch)
} }
} }
@@ -189,7 +181,7 @@ flush_buf :: proc(ch: chan.Chan([]u8), local: ^[dynamic]u8) {
} }
append_path :: proc(buf: ^[dynamic]u8, parent, name: string, trailing_slash: bool) { append_path :: proc(buf: ^[dynamic]u8, parent, name: string, trailing_slash: bool) {
need_sep := len(parent) > 0 && parent[len(parent) - 1] != os.Path_Separator need_sep := len(parent) > 0 && parent[len(parent) - 1] != '/'
size := len(parent) + len(name) + 1 size := len(parent) + len(name) + 1
if need_sep do size += 1 if need_sep do size += 1
if trailing_slash do size += 1 if trailing_slash do size += 1
@@ -200,9 +192,9 @@ append_path :: proc(buf: ^[dynamic]u8, parent, name: string, trailing_slash: boo
pos := old_len pos := old_len
pos += copy(buf[pos:], parent) pos += copy(buf[pos:], parent)
if need_sep {buf[pos] = os.Path_Separator; pos += 1} if need_sep {buf[pos] = '/'; pos += 1}
pos += copy(buf[pos:], name) pos += copy(buf[pos:], name)
if trailing_slash {buf[pos] = os.Path_Separator; pos += 1} if trailing_slash {buf[pos] = '/'; pos += 1}
buf[pos] = '\n' buf[pos] = '\n'
} }
@@ -362,7 +354,6 @@ check_chain :: proc(ctx: ^GIContext, entry_rel: string, is_dir: bool) -> bool {
return false return false
} }
// TODO: Is this a copy of something in the core packages?
relative_to :: proc(entry_rel, base_rel: string) -> string { relative_to :: proc(entry_rel, base_rel: string) -> string {
if len(base_rel) == 0 do return entry_rel if len(base_rel) == 0 do return entry_rel
prefix_len := len(base_rel) prefix_len := len(base_rel)
@@ -443,18 +434,16 @@ load_ignore_patterns :: proc(dir_path: string, in_repo: bool) -> ^Gitignore {
return gi return gi
} }
// TODO: Is this a copy of core package behavior?
join_path :: proc(parent, child: string) -> string { join_path :: proc(parent, child: string) -> string {
need_sep := len(parent) == 0 || parent[len(parent) - 1] != os.Path_Separator need_sep := len(parent) == 0 || parent[len(parent) - 1] != '/'
total := len(parent) + len(child) total := len(parent) + len(child)
if need_sep do total += 1 if need_sep do total += 1
buf := make([]u8, total, context.allocator) buf := make([]u8, total, context.allocator)
pos := copy(buf, parent) pos := copy(buf, parent)
if need_sep { if need_sep {
buf[pos] = os.Path_Separator buf[pos] = '/'
pos += 1 pos += 1
} }
copy(buf[pos:], child) copy(buf[pos:], child)
return string(buf) 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

@@ -36,11 +36,11 @@
inputs', inputs',
... ...
}: }:
let let
mysqlite = pkgs.sqlite.overrideAttrs (old: { mysqlite = pkgs.sqlite.overrideAttrs (old: {
configureFlags = (old.configureFlags or [ ]) ++ [ "--enable-deserialize" ]; configureFlags = (old.configureFlags or [ ]) ++ [ "--enable-deserialize" ];
}); });
in in
{ {
_module.args.pkgs = import nixpkgs { _module.args.pkgs = import nixpkgs {
inherit system; inherit system;
@@ -75,19 +75,10 @@
]; ];
buildInputs = [ buildInputs = [
pkgs.git
pkgs.libsodium pkgs.libsodium
mysqlite mysqlite
]; ];
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

View File

@@ -1,57 +1,14 @@
package main package main
import "base:runtime" import "core:bufio"
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 {
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) defer free_all(context.temp_allocator)
cmd, ok := parse_args(os.args, os.to_writer(os.stdout), os.to_writer(os.stderr)) cmd, ok := parse_args(os.args, os.to_writer(os.stdout), os.to_writer(os.stderr))
defer delete_command(&cmd) // delete flushes automatically defer bufio.writer_flush(cmd.out_buf)
if !ok { if !ok {
return return
} }
@@ -61,6 +18,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":
@@ -86,21 +45,3 @@ main :: proc() {
} }
} }
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)
}
}

View File

@@ -2,7 +2,6 @@ package main
import "core:fmt" import "core:fmt"
import "core:sys/posix" import "core:sys/posix"
import "core:terminal/ansi"
MultiSelect_Result :: enum { MultiSelect_Result :: enum {
Confirm, Confirm,
@@ -37,16 +36,16 @@ multi_select :: proc(
return return
} }
selected = make([dynamic]bool, len(options)) selected = make([dynamic]bool, 0, len(options))
cursor: int = 0 cursor: int = 0
scroll_offset: int = 0 scroll_offset: int = 0
fmt.printf(ansi.CSI + ansi.DECTCEM_HIDE) fmt.printf("\x1b[?25l")
visible := render_options(prompt, options, selected[:], cursor, scroll_offset) visible := render_options(prompt, options, selected[:], cursor, scroll_offset)
raw, ok := enable_raw_mode(posix.STDIN_FILENO) raw, ok := enable_raw_mode(posix.STDIN_FILENO)
if !ok { if !ok {
fmt.printf(ansi.CSI + ansi.DECTCEM_SHOW) fmt.printf("\x1b[?25h")
return return
} }
defer disable_raw_mode(&raw) defer disable_raw_mode(&raw)
@@ -66,18 +65,18 @@ multi_select :: proc(
case .Space: case .Space:
selected[cursor] = !selected[cursor] selected[cursor] = !selected[cursor]
case .Enter: case .Enter:
fmt.printf(ansi.CSI + "%d" + ansi.CUU + ansi.CSI + ansi.ED + ansi.CSI + ansi.DECTCEM_SHOW, visible + 1) fmt.printf("\x1b[%dA\x1b[J\x1b[?25h", visible + 1)
result = .Confirm result = .Confirm
return return
case .Escape: case .Escape:
fmt.printf(ansi.CSI + "%d" + ansi.CUU + ansi.CSI + ansi.ED + ansi.CSI + ansi.DECTCEM_SHOW, visible + 1) fmt.printf("\x1b[%dA\x1b[J\x1b[?25h", visible + 1)
result = .Cancel result = .Cancel
return return
case .Unknown: case .Unknown:
} }
scroll_offset = max(0, min(cursor - MAX_VISIBLE / 2, len(options) - MAX_VISIBLE)) 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) fmt.printf("\x1b[%dA\x1b[0J", visible + 1)
visible = render_options(prompt, options, selected[:], cursor, scroll_offset) visible = render_options(prompt, options, selected[:], cursor, scroll_offset)
} }
} }
@@ -89,7 +88,7 @@ render_options :: proc(
cursor: int, cursor: int,
scroll_offset: int, scroll_offset: int,
) -> int { ) -> int {
fmt.printf(ansi.CSI + ansi.BOLD + ";" + ansi.FG_CYAN + ansi.SGR + "%s" + ANSI_RESET + " (↑/↓ move, space select, enter confirm)\r\n", prompt) fmt.printf("\x1b[1;36m%s\x1b[0m (↑/↓ move, space select, enter confirm)\r\n", prompt)
end := scroll_offset + MAX_VISIBLE end := scroll_offset + MAX_VISIBLE
if end > len(options) { if end > len(options) {
@@ -102,9 +101,9 @@ render_options :: proc(
checkbox = "x" checkbox = "x"
} }
if i == cursor { 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]) fmt.printf("\x1b[1;32m> \x1b[0m[\x1b[32m%s\x1b[0m] %s\r\n", checkbox, options[i])
} else { } else {
fmt.printf(" [" + ansi.CSI + ansi.FAINT + ansi.SGR + "%s" + ANSI_RESET + "] %s\r\n", checkbox, options[i]) fmt.printf(" [\x1b[2m%s\x1b[0m] %s\r\n", checkbox, options[i])
} }
} }

View File

@@ -7,19 +7,18 @@ import "findr"
// 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 { opts := findr.WalkOptions {
pattern = cfg.scan_config.matcher, pattern = cfg.ScanConfig.Matcher,
excludes = cfg.scan_config.exclude[:], excludes = cfg.ScanConfig.Exclude[:],
} }
findr.walk({search_path}, &paths, opts, os.get_processor_core_count()) findr.walk({search_path}, &paths, opts, os.get_processor_core_count())
ok = true ok = true
return return
} }
// The returned values live on the temp_allocator
find_unbacked :: proc(local_files: []string, db_files: []EnvFile) -> []string { find_unbacked :: proc(local_files: []string, db_files: []EnvFile) -> []string {
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)
@@ -30,4 +29,3 @@ find_unbacked :: proc(local_files: []string, db_files: []EnvFile) -> []string {
} }
return unbacked[:] return unbacked[:]
} }

View File

@@ -1,4 +1,3 @@
#+test
package main package main
import "core:fmt" import "core:fmt"
@@ -8,7 +7,8 @@ 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-*") 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,26 +18,23 @@ 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)
@@ -73,16 +70,16 @@ 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-*") 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,42 @@ 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_FREEONCLOSE :: 1
DESERIALIZE_FLAGS :: bit_set[DESERIALIZE_FLAG] DESERIALIZE_RESIZEABLE :: 2
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") @(link_name="sqlite3_serialize")
serialize :: proc(db: Db, zSchema: cstring, piSize: ^i64, mFlags: SERIALIZE_FLAGS) -> [^]u8 --- serialize :: proc(db: ^rawptr, zSchema: cstring, piSize: ^i64, mFlags: u32) -> [^]u8 ---
@(link_name = "sqlite3_deserialize") @(link_name="sqlite3_deserialize")
deserialize :: proc(db: Db, zSchema: cstring, pData: [^]u8, szDb: i64, szBuf: i64, mFlags: DESERIALIZE_FLAGS) -> c.int --- deserialize :: proc(db: ^rawptr, zSchema: cstring, pData: [^]u8, szDb: i64, szBuf: i64, mFlags: u32) -> c.int ---
@(link_name = "sqlite3_malloc64") @(link_name="sqlite3_malloc64")
malloc64 :: proc(n: i64) -> [^]u8 --- malloc64 :: proc(n: i64) -> [^]u8 ---
@(link_name = "sqlite3_free") @(link_name="sqlite3_free")
free :: proc(p: rawptr) --- free :: proc(p: rawptr) ---
} }

140
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 {
width := 0
for i := 0; i < len(str); {
if i + 1 < len(str) && str[i] == 0x1b && str[i + 1] == '[' {
i += 2
for i < len(str) {c := str[i]; i += 1; if c >= 0x40 && c <= 0x7E {break}}
} else {
width += 1
i += 1
} }
} }
return width
}
write_borderless_table :: proc(w: io.Writer, t: ^table.Table) { b: strings.Builder
table.build(t, ansi_aware_width) strings.builder_init(&b)
defer strings.builder_destroy(&b)
defer delete(col_widths)
write_table_separator :: proc(w: io.Writer, tbl: ^table.Table) { hline :: proc(w: io.Writer, b: ^strings.Builder, left, mid, right: string, widths: [dynamic]int) {
io.write_byte(w, '\n') strings.write_string(b, left)
} for i in 0 ..< len(widths) {
for _ in 0 ..< widths[i] + 2 {
if t.caption != "" { strings.write_string(b, "\u2500")
table.write_text_align( }
w, if i < len(widths) - 1 {
fmt.tprintf("%s%s%s", COLOR_HEADINGS, t.caption, ANSI_RESET), strings.write_string(b, mid)
.Left, } else {
0, //t.lpad, strings.write_string(b, right)
0, //t.rpad, }
t.tblw + t.nr_cols - 1 - ansi_aware_width(t.caption) - t.lpad - t.rpad,
)
io.write_byte(w, '\n')
}
write_table_separator(w, t)
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)
} }
fmt.wprintf(w, "%s\n", strings.to_string(b^), flush = false)
strings.builder_reset(b)
} }
write_table_separator(w, t)
hline(w, &b, "\u250c", "\u252c", "\u2510", col_widths)
cell :: proc(b: ^strings.Builder, s: string, width: int) {
extra := len(s) - strings.rune_count(s)
fmt.sbprintf(b, " %-*s \u2502", width + extra, s)
}
strings.write_string(&b, "\u2502")
for i in 0 ..< len(headers) {
cell(&b, headers[i], col_widths[i])
}
fmt.wprintf(w, "%s\n", strings.to_string(b), flush = false)
strings.builder_reset(&b)
hline(w, &b, "\u251c", "\u253c", "\u2524", col_widths)
for r in rows {
strings.write_string(&b, "\u2502")
for i in 0 ..< len(r) {
cell(&b, r[i], col_widths[i])
}
fmt.wprintf(w, "%s\n", strings.to_string(b), flush = false)
strings.builder_reset(&b)
}
hline(w, &b, "\u2514", "\u2534", "\u2518", col_widths)
} }
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,198 @@
#+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)
expected := `┌──────┬─────────────────────────┐
│ Name │ Path │
├──────┼─────────────────────────┤
│ foo │ /home/user/.env │
│ bar │ /home/user/project/.env │
└──────┴─────────────────────────┘
`
testing.expect(
t,
output == expected,
fmt.tprintf(
"table output mismatch\n--- expected ---\n%s\n--- got ---\n%s\n",
expected,
output,
),
)
} }
@(test) @(test)
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)
expected := `┌──────┐
│ Name │
├──────┤
└──────┘
`
testing.expect(
t,
output == expected,
fmt.tprintf(
"table output mismatch\n--- expected ---\n%s\n--- got ---\n%s\n",
expected,
output,
),
)
} }
@(test)
test_render_table_unicode :: proc(t: ^testing.T) {
b: strings.Builder
strings.builder_init(&b)
defer strings.builder_destroy(&b)
headers := []string{"Status", "Detail"}
rows := [][]string{{"\u2713 Available", "ok"}, {"\u2717 Missing", "fail"}}
w := strings.to_writer(&b)
render_table(w, headers, rows)
output := strings.to_string(b)
expected := `┌─────────────┬────────┐
│ Status │ Detail │
├─────────────┼────────┤
│ ✓ Available │ ok │
│ ✗ Missing │ fail │
└─────────────┴────────┘
`
testing.expect(
t,
output == expected,
fmt.tprintf(
"table output mismatch\n--- expected ---\n%s\n--- got ---\n%s\n",
expected,
output,
),
)
}

Binary file not shown.

View File

@@ -1 +1 @@
0.3.0 0.3.1