34 Commits

Author SHA1 Message Date
f825bc2b09 fix: Databases errors are less likely to go unnoticed. 2026-06-24 17:38:13 -04:00
d43b6a75a7 chore: Updated TODOS numbers. 2026-06-24 17:07:19 -04:00
5bc776dd70 refactor: Removed PascalCase names. 2026-06-24 17:06:14 -04:00
bd39e93785 refactor(cli): write_usage and write_command_help now use text/table. 2026-06-24 16:58:12 -04:00
91d0800731 test: Simplified temp directory creaation. 2026-06-24 15:49:33 -04:00
cd3e1b1110 test: Fixed scan_test. 2026-06-24 15:14:12 -04:00
bb6c067b97 refactor: App now crashes if home isn't set. 2026-06-24 14:35:05 -04:00
3331a40053 refactor: Simplified absolute path resolution code. 2026-06-24 14:06:42 -04:00
de1594d9d1 fix: Handled mk_dir error. 2026-06-24 13:46:25 -04:00
dc72ff56fd fix: Fixed some leaks in backup and scan. 2026-06-24 13:28:15 -04:00
78984b57ff refactor: Ignored allocation errors. 2026-06-24 13:08:52 -04:00
9256d94f70 chore: Handled decoding errors. 2026-06-24 11:49:06 -04:00
a11925e720 refactor(ssh): Partially cleaned up. 2026-06-24 11:42:31 -04:00
6139485d13 chore(ssh): Removed is_encrypted_key. 2026-06-22 10:17:28 -04:00
4fcd0b3c9d chore: Cleaned up some files. 2026-06-22 09:28:30 -04:00
63d00a1f55 refactor(config): Switched property names to camel_case. 2026-06-22 09:20:11 -04:00
29415da692 chore: Re-numbered todos. 2026-06-21 23:10:29 -04:00
f703a8df5d refactor(db.odin): Renamed fields for consistency. 2026-06-21 22:58:43 -04:00
2683e2a00f refactor(sqlite): Used distinct types for Db and Stmt pointers.
Also made some other improvements to it.
2026-06-21 16:52:21 -04:00
9683216efe refactor(sqlite): Removed db_ prefix from db_open and db_close. 2026-06-20 18:49:56 -04:00
92faab2706 refactor: Used the official table package. 2026-06-19 19:35:42 -04:00
f2da8b9f22 refactor: Used ansi project constants instead of inlines. 2026-06-19 18:17:42 -04:00
4097e37d9f chore: Made some code more windows friendly. 2026-06-19 18:09:40 -04:00
f5eeb55dd1 refactor: Removed dead code. 2026-06-19 18:09:40 -04:00
e4b32a9909 test: Added spall config back. 2026-06-19 17:33:43 -04:00
1562fb3665 fix: Fixed vet errors. 2026-06-19 17:33:43 -04:00
c7c254f6f2 fix: Fixed leaks. 2026-06-19 15:32:44 -04:00
0083e4e0db fix(scan): Fixed a bug preventing TUI from working. 2026-06-19 14:39:53 -04:00
33cd7c4eda feat: Colorized console output. 2026-06-19 13:45:55 -04:00
a03d388a0c refactor: Allocations now use the temp_allocator more frequently. 2026-06-19 07:50:57 -04:00
84764d03a6 refactor: Cleaned up the sync and scan commands. 2026-06-19 07:29:51 -04:00
0523c09601 refactor: Gave db its own allocator. 2026-06-18 17:29:28 -04:00
f137fc79fc refactor: Fixed up env_file_sync. 2026-06-18 16:35:03 -04:00
8d5e50566b ci: Fixed release-please. 2026-06-18 10:43:47 -04:00
39 changed files with 1240 additions and 1528 deletions

View File

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

1
.gitignore vendored
View File

@@ -11,6 +11,7 @@ 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

@@ -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 help .PHONY: all clean cleanall build-linux build-darwin compress release profile help
# Default target # Default target
all: release clean all: release clean
@@ -66,6 +66,12 @@ 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..."
@@ -84,6 +90,7 @@ 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

@@ -1,268 +0,0 @@
# 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

@@ -1,59 +1,52 @@
# TODOs # TODOs
1. Consider giving db its own allocator 1. Commands are still leaking.
27. Commands are still leaking. 2. Add color flag and support non colored output.
2. Generate md and man pages again. 3. Rewrite `write_command_help` to use text/tables
3. **db.odin:324-327** — Map iteration (`remote_set`) is non-deterministic. Same file can produce different JSON on each backup, causing spurious DB diffs. Sort remotes before storing. 4. Generate md and man pages again.
4. Make sure official path separators are used when appropriate, rather than '/'. 5. Json may be an expensive encoding for remotes. Confirm with spall, and use null terminated strings if necessary.
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. Make sure official path separators are used when appropriate, rather than '/'.
6. **cmd_restore.odin:44**`os.mkdir_all` error silently discarded. Subsequent write failure will be confusing. 7. Consistently ignore allocator errors
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. Check for prealloc opportunities. i.e. `make([dynamic]string)` -> `make([dynamic]string, 5)`.
10. **db.odin:115**`json.unmarshal_string` error not checked. Malformed JSON silently produces empty/partial data. 9. Add a text filter to the multi_select.
11. **db.odin:352-353**`hex.encode` error ignored. `string(hex_bytes)` aliases the byte slice. 10. Add tests for untested commands.
12. **cmd_sync.odin:80, cmd_list.odin:33**`make([]string, 2)` for table rows never freed. Leaks per row. Defer to memory pass. 11. add --format -f flag to commands that draw tables.
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. Replace `testing.expect` calls with `testing.expect_value` calls where appropriate.
14. Check for prealloc opportunities. i.e. `make([dynamic]string)` -> `make([dynamic]string, 5)`. 13. procedures should be ordered by use, main at the top, then in the order they are called from main.
15. Add a text filter to the multi_select. 14. Shell completion
17. Add tests for untested commands. 15. Bring back windows support / cross-compilation.
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. 16. Test all cmds / terminal branches.
20. add --format -f flag to commands that draw tables. 17. Fix error messages to use fmt.eprintf (stderr) instead of fmt.printf (stdout)
21. Replace `testing.expect` calls with `testing.expect_value` calls where appropriate. 18. Pass allocator to findr?
22. Change struct field names from PascalCase to snake_case. 19. Update `read_wire_string` to use a slice.
23. procedures should be ordered by use, main at the top, then in the order they are called from main.
24. Shell completion
25. Bring back windows support / cross-compilation.
26. Test all cmds / terminal branches.
## Double-check AI output ## 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
- [ ] cmd_edit_config.odin - [x] 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
@@ -64,7 +57,7 @@
- [x] cmd_scan.odin - [x] cmd_scan.odin
- [x] cmd_sync.odin - [x] cmd_sync.odin
- [x] cmd_version.odin - [x] cmd_version.odin
- [ ] config.odin - [x] config.odin
- [ ] config_test.odin - [ ] config_test.odin
- [ ] crypto.odin - [ ] crypto.odin
- [ ] crypto_test.odin - [ ] crypto_test.odin
@@ -73,10 +66,10 @@
- [ ] db_test.odin - [ ] db_test.odin
- [x] main.odin - [x] main.odin
- [x] prompt.odin - [x] prompt.odin
- [ ] scan.odin - [x] scan.odin
- [ ] scan_test.odin - [ ] scan_test.odin
- [ ] sodium.odin - [ ] sodium.odin
- [ ] sqlite/sqlite.odin - [x] sqlite/sqlite.odin
- [ ] ssh.odin - [ ] ssh.odin
- [ ] ssh_test.odin - [ ] ssh_test.odin
- [ ] table.odin - [ ] table.odin

120
cli.odin
View File

@@ -5,6 +5,7 @@ import "core:fmt"
import "core:io" import "core:io"
import "core:os" import "core:os"
import "core:strings" import "core:strings"
import "core:text/table"
Command :: struct { Command :: struct {
name: string, name: string,
@@ -75,6 +76,7 @@ parse_args :: proc(args: []string, out: io.Stream, err: io.Stream) -> (cmd: Comm
cmd.flags = make(map[string]string) cmd.flags = make(map[string]string)
cmd.bool_set = make(map[string]bool) cmd.bool_set = make(map[string]bool)
// TODO: Optimize loop?
i := 2 i := 2
for i < len(args) { for i < len(args) {
arg := args[i] arg := args[i]
@@ -102,9 +104,10 @@ parse_args :: proc(args: []string, out: io.Stream, err: io.Stream) -> (cmd: Comm
} }
} }
if val, ok := cmd.flags["config-file"]; ok { val: string = ---
if val, ok = cmd.flags["config-file"]; ok {
cmd.config_path = val cmd.config_path = val
} else if val, ok := cmd.flags["c"]; ok { } else if val, ok = cmd.flags["c"]; ok {
cmd.config_path = val cmd.config_path = val
} else { } else {
// FIXME: Handle err // FIXME: Handle err
@@ -136,13 +139,38 @@ write_command_help :: proc(name: string, w: io.Writer) -> bool {
return false return false
} }
fmt.wprintf(w, "Usage: %s [flags]\n\n", info.usage, flush = false) fmt.wprintf(
fmt.wprintf(w, "%s\n", info.short, flush = false) 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 { if len(info.aliases) > 0 {
fmt.wprintf(w, "\nAliases:\n %s", info.name, flush = false) 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 { for a in info.aliases {
fmt.wprintf(w, ", %s", a, flush = false) fmt.wprintf(w, ", " + COLOR_COMMANDS + "%s" + ANSI_RESET, a, flush = false)
} }
fmt.wprintf(w, "\n", flush = false) fmt.wprintf(w, "\n", flush = false)
} }
@@ -153,7 +181,20 @@ write_command_help :: proc(name: string, w: io.Writer) -> bool {
fmt.wprintf( fmt.wprintf(
w, w,
"\nFlags:\n -h, --help help for %s\n -c, --config-file <path> config file (default \"~/.envr/config.json\")\n", "\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, info.name,
flush = false, flush = false,
) )
@@ -178,11 +219,11 @@ find_command :: proc(name: string) -> (CommandInfo, bool) {
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, age encrypted database. `envr keeps your .env synced to a local, 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 ~/data.age All your data is stored in ~/.envr/data.envr
Getting started is easy: Getting started is easy:
@@ -209,50 +250,57 @@ at before, restore your backup with:
> envr restore ~/<path to repository>/.env > envr restore ~/<path to repository>/.env
Usage: %sUsage:%s
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_start := len(c.name) name := c.name
fmt.wprintf(w, "%s", c.name, flush = false) // TODO: Can we do better?
for a in c.aliases { for a in c.aliases {
fmt.wprintf(w, ", %s", a, flush = false) name = strings.join([]string{name, a}, ", ", tbl.format_allocator)
name_start += len(a) + 2
} }
padding := 20 - name_start table.row(&tbl, table.format(&tbl, "%s%s%s", COLOR_COMMANDS, name, ANSI_RESET), c.short)
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")`,
)
write_borderless_table(w, &tbl)
fmt.wprintf( fmt.wprintf(
w, w,
` `Use "%senvr%s [command] --help" for more information about a command.`,
Flags: COLOR_FLAGS,
-h, --help help for envr ANSI_RESET,
-c, --config-file <path> config file (default "~/.envr/config.json")
Use "envr [command] --help" for more information about a command.
`,
flush = false, flush = false,
) )
} }
has_flag :: proc(cmd: ^Command, name: string) -> bool { has_flag :: proc(cmd: ^Command, name: string) -> bool {
_, ok := cmd.flags[name] return name in cmd.flags || name in cmd.bool_set
if ok {
return true
}
_, ok2 := cmd.bool_set[name]
return ok2
} }
delete_command :: proc(cmd: ^Command) { delete_command :: proc(cmd: ^Command) {

View File

@@ -1,5 +1,6 @@
#+feature dynamic-literals #+feature dynamic-literals
#+test #+test
package main
import "core:bufio" import "core:bufio"
import "core:fmt" import "core:fmt"
@@ -57,7 +58,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, "[command] --help"), "missing help hint") testing.expect(t, strings.contains(text, "[command] --help"), "missing help hint")
} }
@(test) @(test)
test_command_help_backup :: proc(t: ^testing.T) { test_command_help_backup :: proc(t: ^testing.T) {

View File

@@ -15,7 +15,10 @@ 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
} }

View File

@@ -4,30 +4,25 @@ 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)
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 { if abs_err != nil {
fmt.wprintf(cmd.err, "Error getting absolute path: %v\n", abs_err, flush = false) fmt.wprintf(cmd.err, "Error getting absolute path: %v\n", abs_err, flush = false)
return return
} }
abs_path = resolved
}
db, db_ok := db_open(cmd.config_path) db, db_ok := db_open(cmd.config_path)
if !db_ok { if !db_ok {
@@ -35,27 +30,26 @@ cmd_check :: proc(cmd: ^Command) {
} }
defer db_close(&db) defer db_close(&db)
is_dir := os.is_directory(abs_path) is_dir := os.is_directory(check_path)
files_in_path: [dynamic]string // TODO: set a reasonable default
files_in_path := make([dynamic]string, context.temp_allocator)
if is_dir { if is_dir {
scanned, scan_ok := scan_path(abs_path, db.cfg) scanned, scan_ok := scan_path(check_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, abs_path) append(&files_in_path, check_path)
} }
db_files, list_ok := db_list(&db) db_files, list_ok := db_list(&db)
if !list_ok { if !list_ok {
return return
} }
defer delete(db_files)
defer for &file in db_files {delete_envfile(&file)}
not_backed := find_unbacked(files_in_path[:], db_files[:]) not_backed := find_unbacked(files_in_path[:], db_files[:])

View File

@@ -1,3 +1,4 @@
#+test
package main package main
import "core:fmt" import "core:fmt"
@@ -6,7 +7,7 @@ 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(t, len(result) == 1, fmt.tprintf("expected 1 unbacked, got %d", len(result))) testing.expect(t, len(result) == 1, fmt.tprintf("expected 1 unbacked, got %d", len(result)))
@@ -22,7 +23,7 @@ test_find_unbacked_finds_missing :: proc(t: ^testing.T) {
@(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(t, len(result) == 0, fmt.tprintf("expected 0 unbacked, got %d", len(result))) testing.expect(t, len(result) == 0, fmt.tprintf("expected 0 unbacked, got %d", len(result)))
@@ -31,7 +32,7 @@ test_find_unbacked_all_backed :: proc(t: ^testing.T) {
@(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(t, len(result) == 0, fmt.tprintf("expected 0 unbacked, got %d", len(result))) testing.expect(t, len(result) == 0, fmt.tprintf("expected 0 unbacked, got %d", len(result)))

View File

@@ -12,8 +12,7 @@ cmd_edit_config :: proc(cmd: ^Command) {
config_path := cmd.config_path config_path := cmd.config_path
_, stat_err := os.stat(config_path, context.allocator) if !os.exists(config_path) {
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",
@@ -42,6 +41,8 @@ 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,6 +1,7 @@
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 := has_flag(cmd, "force") || has_flag(cmd, "f") force := has_flag(cmd, "force") || has_flag(cmd, "f")
@@ -32,7 +33,7 @@ Generate one with: ssh-keygen -t ed25519`, flush = false)
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(cmd.out, "\x1b[2mCancelled.\x1b[0m", flush = false) fmt.wprintln(cmd.out, ansi.CSI + ansi.FAINT + ansi.SGR + "Cancelled." + ANSI_RESET, flush = false)
return return
} }

View File

@@ -6,10 +6,11 @@ import "core:os"
import "core:path/filepath" import "core:path/filepath"
import "core:strings" import "core:strings"
import "core:terminal" import "core:terminal"
import "core:text/table"
ListEntry :: struct { ListEntry :: struct {
Directory: string `json:"directory"`, dir: string `json:"directory"`,
Path: string `json:"path"`, path: string `json:"path"`,
} }
// TODO: Support --format flag // TODO: Support --format flag
@@ -25,33 +26,39 @@ cmd_list :: proc(cmd: ^Command) {
if !list_ok { if !list_ok {
return return
} }
defer delete(rows)
defer for &row in rows {delete_envfile(&row)}
if terminal.is_terminal(os.stdout) { if terminal.is_terminal(os.stdout) {
headers := []string{"Directory", "Path"} t: table.Table
table_rows := make([dynamic][]string, 0, len(rows), context.temp_allocator) table.init(&t, context.temp_allocator, 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({row.Dir, "/"}, context.temp_allocator) dir_str := strings.concatenate(
filename := filepath.base(row.Path) {row.dir, os.Path_Separator_String},
row_slice := make([]string, 2, context.temp_allocator) context.temp_allocator,
row_slice[0] = dir_str )
row_slice[1] = filename filename := filepath.base(row.path)
append(&table_rows, row_slice)
table.row(&t, dir_str, filename)
} }
render_table(cmd.out, headers, table_rows[:]) table.write_decorated_table(cmd.out, &t, decorations, ansi_aware_width)
} else { } else {
// TODO: Should we instead print full entries here? // TODO: Should we instead print full entries here?
entries: [dynamic]ListEntry entries: [dynamic]ListEntry
for row in rows { for row in rows {
filename := filepath.base(row.Path) filename := filepath.base(row.path)
append( append(
&entries, &entries,
ListEntry { ListEntry {
Directory = strings.concatenate({row.Dir, "/"}, context.temp_allocator), dir = strings.concatenate({row.dir, "/"}, context.temp_allocator),
Path = filename, path = filename,
}, },
) )
} }

View File

@@ -1,3 +1,4 @@
#+test
package main package main
import "core:path/filepath" import "core:path/filepath"

View File

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

View File

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

View File

@@ -16,19 +16,12 @@ 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)
// 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 { if abs_err != nil {
fmt.wprintf(cmd.err, "Error getting absolute path: %v\n", abs_err, flush = false) fmt.wprintf(cmd.err, "Error getting absolute path: %v\n", abs_err, flush = false)
return return
} }
abs_path = resolved
}
db, db_ok := db_open(cmd.config_path) db, db_ok := db_open(cmd.config_path)
if !db_ok { if !db_ok {
@@ -41,15 +34,20 @@ cmd_restore :: proc(cmd: ^Command) {
return return
} }
dir := filepath.dir(file.Path) dir := filepath.dir(file.path)
os.mkdir_all(dir) if err := os.mkdir_all(dir); err != nil {
fmt.wprintf(cmd.err, "Failed to create directory: %v\n", err, flush = false)
write_err := os.write_entire_file(file.Path, file.contents)
if write_err != nil {
fmt.wprintf(cmd.err, "Error writing file: %v\n", write_err, flush = false)
return return
} }
fmt.wprintf(cmd.out, "Restored %s\n", file.Path, flush = false) write_err := os.write_entire_file(file.path, file.contents)
if write_err != nil {
fmt.wprintf(cmd.err, "Error writing file: %v\n", write_err, flush = false)
return
}
fmt.wprintf(cmd.out, "Restored %s\n", file.path, flush = false)
} }

View File

@@ -4,6 +4,7 @@ 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.config_path) db, db_ok := db_open(cmd.config_path)
@@ -12,7 +13,7 @@ cmd_scan :: proc(cmd: ^Command) {
} }
defer db_close(&db) defer db_close(&db)
search_dirs := search_paths(db.cfg) search_dirs := search_paths(db.cfg, context.temp_allocator)
if len(search_dirs) == 0 { if len(search_dirs) == 0 {
fmt.wprintln( fmt.wprintln(
cmd.err, cmd.err,
@@ -23,9 +24,15 @@ cmd_scan :: proc(cmd: ^Command) {
} }
// TODO: Figure out a sane default // TODO: Figure out a sane default
all_files: [dynamic]string // Can't use temp allocator becuase strings inside are copied to context.allocator
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
@@ -65,7 +72,11 @@ 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(cmd.out, "\x1b[2mCancelled.\x1b[0m", flush = false) fmt.wprintln(
cmd.out,
ansi.CSI + ansi.FAINT + ansi.SGR + "Cancelled." + ANSI_RESET,
flush = false,
)
return return
} }
@@ -74,7 +85,9 @@ 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
@@ -89,12 +102,23 @@ cmd_scan :: proc(cmd: ^Command) {
if added_count > 0 { if added_count > 0 {
fmt.wprintf( fmt.wprintf(
cmd.out, cmd.out,
"\x1b[1;32mSuccessfully added %d file(s) to backup.\x1b[0m\n", ansi.CSI +
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(cmd.out, "\x1b[2mNo files were added.\x1b[0m", flush = false) fmt.wprintln(
cmd.out,
ansi.CSI + ansi.FAINT + ansi.SGR + "No files were added." + ANSI_RESET,
flush = false,
)
} }
} }

View File

@@ -3,12 +3,12 @@ package main
import "core:encoding/json" import "core:encoding/json"
import "core:fmt" import "core:fmt"
import "core:os" import "core:os"
import "core:strings"
import "core:terminal" import "core:terminal"
import "core:text/table"
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.
@@ -24,68 +24,50 @@ cmd_sync :: proc(cmd: ^Command) {
if !list_ok { if !list_ok {
return return
} }
defer delete(files)
// TODO: Set sane default size results := make([]SyncEntry, len(files), context.temp_allocator)
results: [dynamic]SyncEntry
defer delete(results)
for &file in files { for &file, i in files {
old_path: string result, err := db_sync(&db, &file)
old_path, _ = strings.clone(file.Path, context.temp_allocator)
result, err_msg := db_sync(&db, &file)
status: string status: string
is_dir_updated := .DirUpdated in result if err != .None {
status = sync_error_message(err)
switch { } else if .BackedUp in result {
case .Error in result: status = .DirUpdated in result ? "Moved & Backed Up" : "Backed Up"
if len(err_msg) > 0 { } else if .Restored in result {
status = err_msg status = .DirUpdated in result ? "Moved & Restored" : "Restored"
} else { } else if .DirUpdated in result {
status = "error"
}
case .BackedUp in result:
status = "Backed Up"
case .Restored in result:
status = "Restored"
case .DirUpdated in result:
status = "Moved" status = "Moved"
case: } else {
status = "OK" status = "OK"
} }
if is_dir_updated { results[i] = SyncEntry {
if !db_delete(&db, old_path) { path = file.path,
return status = status,
} }
} }
if db_update_required(result) {
if !db_insert(&db, file) {
return
}
}
path_str, _ := strings.clone(file.Path)
status_str, _ := strings.clone(status)
append(&results, SyncEntry{Path = path_str, Status = status_str})
}
if terminal.is_terminal(os.stdout) { if terminal.is_terminal(os.stdout) {
headers := []string{"File", "Status"} t: table.Table
table_rows := make([dynamic][]string, 0, len(results)) table.init(&t, context.temp_allocator, context.temp_allocator)
table.padding(&t, 1, 1)
table.aligned_header_of_values(
&t,
.Center,
COLOR_TABLE_HEADING + "File" + ANSI_RESET,
COLOR_TABLE_HEADING + "Status" + ANSI_RESET,
)
for res in results { for res in results {
row_slice := make([]string, 2) table.row(&t, res.path, res.status)
row_slice[0] = res.Path
row_slice[1] = res.Status
append(&table_rows, row_slice)
} }
render_table(cmd.out, headers, table_rows[:]) table.write_decorated_table(cmd.out, &t, decorations, ansi_aware_width)
} else { } else {
data, marshal_err := json.marshal(results[:]) data, marshal_err := json.marshal(results[:], 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)
return return
@@ -94,3 +76,23 @@ 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"
}

17
colors.odin Normal file
View File

@@ -0,0 +1,17 @@
package main
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

View File

@@ -1,5 +1,6 @@
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"
@@ -8,34 +9,33 @@ import "core:strings"
import "findr" import "findr"
SshKeyPair :: struct {
Private: string `json:"private"`,
Public: string `json:"public"`,
}
ScanConfig :: struct {
Matcher: string `json:"matcher"`,
Exclude: [dynamic]string `json:"exclude"`,
Include: [dynamic]string `json:"include"`,
}
Config :: struct { Config :: struct {
Keys: [dynamic]SshKeyPair `json:"keys"`, keys: [dynamic]SshKeyPair `json:"keys"`,
ScanConfig: ScanConfig `json:"scan"`, scan_config: ScanConfig `json:"scan"`,
config_path: string `json:"-"`, config_path: string `json:"-"`,
} }
load_config :: proc(config_path: string) -> (Config, bool) { SshKeyPair :: struct {
data, read_err := os.read_entire_file_from_path(config_path, context.allocator) private: string `json:"private"`,
public: string `json:"public"`,
}
ScanConfig :: struct {
matcher: string `json:"matcher"`,
exclude: [dynamic]string `json:"exclude"`,
include: [dynamic]string `json:"include"`,
}
load_config :: proc(config_path: string, allocator := context.allocator) -> (Config, bool) {
// TODO: Should we use context.allocator + defer delete()?
data, read_err := os.read_entire_file_from_path(config_path, context.temp_allocator)
if read_err != nil { if read_err != nil {
fmt.println("No config file found. Please run `envr init` to generate one.") fmt.println("No config file found. Please run `envr init` to generate one.")
return Config{}, false return Config{}, false
} }
defer delete(data)
cfg: Config cfg: Config
// TODO: use json 5 err := json.unmarshal(data, &cfg, .JSON5, allocator)
err := json.unmarshal(data, &cfg)
if err != nil { if err != nil {
fmt.printf("Error parsing config: %v\n", err) fmt.printf("Error parsing config: %v\n", err)
return Config{}, false return Config{}, false
@@ -53,24 +53,24 @@ default_config_path :: proc(home: string, allocator := context.allocator) -> str
return path return path
} }
delete_config :: proc(cfg: ^Config) { delete_config :: proc(cfg: ^Config, allocator := context.allocator) {
for key in cfg.Keys { for key in cfg.keys {
delete(key.Private) delete(key.private, allocator)
delete(key.Public) delete(key.public, allocator)
} }
delete(cfg.Keys) delete(cfg.keys)
delete(cfg.ScanConfig.Matcher) delete(cfg.scan_config.matcher, allocator)
for exclude in cfg.ScanConfig.Exclude { for exclude in cfg.scan_config.exclude {
delete(exclude) delete(exclude, allocator)
} }
delete(cfg.ScanConfig.Exclude) delete(cfg.scan_config.exclude)
for include in cfg.ScanConfig.Include { for include in cfg.scan_config.include {
delete(include) delete(include, allocator)
} }
delete(cfg.ScanConfig.Include) delete(cfg.scan_config.include)
} }
save_config :: proc(cfg: Config, force: bool = false) -> bool { save_config :: proc(cfg: Config, force: bool = false) -> bool {
@@ -85,9 +85,9 @@ save_config :: proc(cfg: Config, force: bool = false) -> bool {
} }
if os.exists(cfg.config_path) && !force { if os.exists(cfg.config_path) && !force {
info, stat_err := os.stat(cfg.config_path, context.allocator) info, stat_err := os.stat(cfg.config_path, context.temp_allocator)
if stat_err == nil { if stat_err == nil {
defer os.file_info_delete(info, context.allocator) defer os.file_info_delete(info, context.temp_allocator)
if info.size > 0 { if info.size > 0 {
fmt.println("Config file already exists. Run again with --force to reinitialize.") fmt.println("Config file already exists. Run again with --force to reinitialize.")
return false return false
@@ -95,12 +95,15 @@ save_config :: proc(cfg: Config, force: bool = false) -> bool {
} }
} }
data, marshal_err := json.marshal(cfg, {pretty = true, use_spaces = true, spaces = 2}) data, marshal_err := json.marshal(
cfg,
{pretty = true, use_spaces = true, spaces = 2},
context.temp_allocator,
)
if marshal_err != nil { if marshal_err != nil {
fmt.printf("Error marshaling config: %v\n", marshal_err) fmt.printf("Error marshaling config: %v\n", marshal_err)
return false return false
} }
defer delete(data)
write_err := os.write_entire_file(cfg.config_path, data) write_err := os.write_entire_file(cfg.config_path, data)
if write_err != nil { if write_err != nil {
@@ -121,9 +124,11 @@ new_config :: proc(
// TODO: Is this bad? // TODO: Is this bad?
priv_key := strings.clone(priv) priv_key := strings.clone(priv)
pub, _ := strings.concatenate([]string{priv_key, ".pub"}) pub, _ := strings.concatenate([]string{priv_key, ".pub"})
append(&keys, SshKeyPair{Private = priv_key, Public = pub}) append(&keys, SshKeyPair{private = priv_key, public = pub})
} }
// If we don't clone the strings, the cleanup semantics differ for Db created
// configs vs user created configs.
exclude := make([dynamic]string, 0, 4) exclude := make([dynamic]string, 0, 4)
append(&exclude, strings.clone("*\\.envrc")) append(&exclude, strings.clone("*\\.envrc"))
append(&exclude, strings.clone("\\.local/")) append(&exclude, strings.clone("\\.local/"))
@@ -134,12 +139,12 @@ new_config :: proc(
append(&include, strings.clone("~")) append(&include, strings.clone("~"))
scan_cfg := ScanConfig { scan_cfg := ScanConfig {
Matcher = strings.clone("\\.env"), matcher = strings.clone("\\.env"),
Exclude = exclude, exclude = exclude,
Include = include, include = include,
} }
return Config{Keys = keys, ScanConfig = scan_cfg, config_path = cfg_path} 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) {
@@ -188,32 +193,41 @@ find_ssh_private_keys :: proc() -> (keys: [dynamic]string, ok: bool) {
return return
} }
find_git_roots :: proc(cfg: Config) -> (roots: [dynamic]string, ok: bool) { find_git_roots :: proc(
paths := search_paths(cfg) cfg: Config,
allocator := context.temp_allocator,
) -> (
roots: [dynamic]string,
ok: bool,
) {
paths := search_paths(cfg, allocator)
// TODO: Pass allocator to findr
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) -> (paths: [dynamic]string) { search_paths :: proc(cfg: Config, allocator := context.allocator) -> [dynamic]string {
// TODO: Is this okay? home, err := os.user_home_dir(context.temp_allocator)
// TODO: handle error if err != nil {
home, _ := os.user_home_dir(context.temp_allocator) panic("Failed to find home directory")
}
for include in cfg.ScanConfig.Include { paths := new_clone(cfg.scan_config.include, allocator)
// TODO: Do we need to manually expand ~/ in odin?
expanded, _ := strings.replace(include, "~", home, 1) for &include in paths {
expanded, _ := strings.replace(include, "~", home, 1, allocator)
if filepath.is_abs(expanded) { if filepath.is_abs(expanded) {
append(&paths, expanded) include = expanded
} else { } else {
defer delete(expanded) // TODO: show errors?
resolved, err := filepath.abs(expanded) resolved, err := filepath.abs(expanded, allocator)
if err == nil { if err == nil {
append(&paths, resolved) include = resolved
} }
} }
} }
return return paths^
} }
envr_dir :: proc(config_path: string) -> string { envr_dir :: proc(config_path: string) -> string {
@@ -221,8 +235,13 @@ envr_dir :: proc(config_path: string) -> string {
} }
// User is responsible for freeing the path // User is responsible for freeing the path
data_path :: proc(config_path: string, allocator := context.allocator) -> string { data_path :: proc(
path, _ := filepath.join([]string{envr_dir(config_path), "data.envr"}, allocator) config_path: string,
return path allocator := context.allocator,
) -> (
string,
runtime.Allocator_Error,
) #optional_allocator_error {
return filepath.join([]string{envr_dir(config_path), "data.envr"}, allocator)
} }

View File

@@ -1,3 +1,4 @@
#+test
package main package main
import "core:fmt" import "core:fmt"
@@ -15,11 +16,11 @@ 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(t, len(cfg.Keys) == 1, "should have 1 key") testing.expect(t, len(cfg.keys) == 1, "should have 1 key")
testing.expect(t, cfg.Keys[0].Private == "/home/user/.ssh/id_ed25519", "Private path mismatch") testing.expect(t, cfg.keys[0].private == "/home/user/.ssh/id_ed25519", "Private path mismatch")
testing.expect( testing.expect(
t, t,
cfg.Keys[0].Public == "/home/user/.ssh/id_ed25519.pub", cfg.keys[0].public == "/home/user/.ssh/id_ed25519.pub",
"Public path mismatch", "Public path mismatch",
) )
} }
@@ -30,9 +31,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(t, len(cfg.Keys) == 2, "should have 2 keys") testing.expect(t, len(cfg.keys) == 2, "should have 2 keys")
testing.expect(t, cfg.Keys[0].Private == "/home/user/.ssh/id_ed25519") testing.expect(t, cfg.keys[0].private == "/home/user/.ssh/id_ed25519")
testing.expect(t, cfg.Keys[1].Private == "/home/user/.ssh/id_rsa") testing.expect(t, cfg.keys[1].private == "/home/user/.ssh/id_rsa")
} }
@(test) @(test)
@@ -41,7 +42,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(t, len(cfg.Keys) == 0, "should have 0 keys") testing.expect(t, len(cfg.keys) == 0, "should have 0 keys")
} }
@(test) @(test)
@@ -50,10 +51,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(t, cfg.ScanConfig.Matcher == "\\.env", "matcher should be \\.env") testing.expect(t, cfg.scan_config.matcher == "\\.env", "matcher should be \\.env")
testing.expect(t, len(cfg.ScanConfig.Exclude) == 4, "should have 4 exclude patterns") testing.expect(t, len(cfg.scan_config.exclude) == 4, "should have 4 exclude patterns")
testing.expect(t, len(cfg.ScanConfig.Include) == 1, "should have 1 include path") testing.expect(t, len(cfg.scan_config.include) == 1, "should have 1 include path")
testing.expect(t, cfg.ScanConfig.Include[0] == "~", "include should be ~") testing.expect(t, cfg.scan_config.include[0] == "~", "include should be ~")
} }
@(test) @(test)
@@ -64,14 +65,13 @@ 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(t, cfg.ScanConfig.Exclude[i] == expected[i]) testing.expect(t, cfg.scan_config.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 := fmt.tprintf("/tmp/envr-test-cfg-rt-%d", os.get_pid()) base := test_temp_dir(t, "envr-test-cfg-rt-*")
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)
@@ -87,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(t, len(loaded.Keys) == 1, "should have 1 key") testing.expect(t, len(loaded.keys) == 1, "should have 1 key")
testing.expect(t, loaded.Keys[0].Private == "/home/user/.ssh/id_ed25519") testing.expect(t, loaded.keys[0].private == "/home/user/.ssh/id_ed25519")
testing.expect(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(t, loaded.ScanConfig.Matcher == "\\.env") testing.expect(t, loaded.scan_config.matcher == "\\.env")
testing.expect(t, len(loaded.ScanConfig.Exclude) == 4) testing.expect(t, len(loaded.scan_config.exclude) == 4)
testing.expect(t, len(loaded.ScanConfig.Include) == 1) testing.expect(t, len(loaded.scan_config.include) == 1)
testing.expect(t, loaded.ScanConfig.Include[0] == "~") testing.expect(t, loaded.scan_config.include[0] == "~")
} }
@(test) @(test)
@@ -104,8 +104,7 @@ 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 := fmt.tprintf("/tmp/envr-test-cfg-noclobber-%d", os.get_pid()) base := test_temp_dir(t, "envr-test-cfg-noclobber-*")
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)
@@ -122,8 +121,7 @@ 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 := fmt.tprintf("/tmp/envr-test-cfg-force-%d", os.get_pid()) base := test_temp_dir(t, "envr-test-cfg-force-*")
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)
@@ -142,10 +140,10 @@ 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(t, len(loaded.Keys) == 1, "should have 1 key") testing.expect(t, len(loaded.keys) == 1, "should have 1 key")
testing.expect( testing.expect(
t, t,
loaded.Keys[0].Private == "/home/user/.ssh/key2", loaded.keys[0].private == "/home/user/.ssh/key2",
"should be the overwritten key", "should be the overwritten key",
) )
} }
@@ -185,16 +183,12 @@ 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 {
ScanConfig = ScanConfig{Include = make([dynamic]string, 0, 1)}, scan_config = ScanConfig{include = make([dynamic]string, 0, 1)},
} }
defer delete(cfg.ScanConfig.Include) append(&cfg.scan_config.include, "~")
append(&cfg.ScanConfig.Include, "~") defer delete(cfg.scan_config.include)
paths := search_paths(cfg) paths := search_paths(cfg, context.temp_allocator)
defer delete(paths)
for path in paths {
defer delete(path)
}
testing.expect(t, len(paths) == 1, "should have 1 path") testing.expect(t, len(paths) == 1, "should have 1 path")
if len(paths) > 0 { if len(paths) > 0 {

View File

@@ -33,12 +33,12 @@ init_sodium :: proc "contextless" () {
} }
} }
// 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) x25519_pairs, pairs_ok := ssh_to_x25519(keys, context.temp_allocator)
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 +47,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) secret_ct := make([]u8, ct_len, context.temp_allocator)
pt_ptr: [^]u8 pt_ptr: [^]u8
if len(plaintext) > 0 { if len(plaintext) > 0 {
pt_ptr = &plaintext[0] pt_ptr = &plaintext[0]
@@ -66,7 +66,7 @@ encrypt :: proc(plaintext: []u8, keys: []SshKeyPair) -> (ciphertext: []u8, ok: b
} }
num_recipients := u32(len(x25519_pairs)) num_recipients := u32(len(x25519_pairs))
entries := make([]RecipientEntry, num_recipients) entries := make([]RecipientEntry, num_recipients, context.temp_allocator)
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 {
@@ -126,8 +126,6 @@ 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
} }
@@ -176,11 +174,10 @@ 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) x25519_pairs, pairs_ok := ssh_to_x25519(keys, context.temp_allocator)
if !pairs_ok { if !pairs_ok {
return return
} }
defer delete(x25519_pairs)
found := false found := false
matched_pi := 0 matched_pi := 0
@@ -272,33 +269,39 @@ decrypt :: proc(ciphertext: []u8, keys: []SshKeyPair) -> (plaintext: []u8, ok: b
return return
} }
ssh_to_x25519 :: proc(keys: []SshKeyPair) -> (pairs: []X25519Keypair, ok: bool) { ssh_to_x25519 :: proc(
keys: []SshKeyPair,
allocator := context.temp_allocator,
) -> (
[]X25519Keypair,
bool,
) {
if len(keys) == 0 { if len(keys) == 0 {
return return {}, false
} }
pairs = make([]X25519Keypair, len(keys)) pairs := make([]X25519Keypair, len(keys), allocator)
for i in 0 ..< len(keys) { for i in 0 ..< len(keys) {
ssh_kp, parse_ok := parse_ssh_private_key(keys[i].Private) ssh_kp, parse_ok := parse_ssh_private_key(keys[i].private)
if !parse_ok { if !parse_ok {
fmt.printf("Error: failed to parse SSH private key: %s\n", keys[i].Private) fmt.printf("Error: failed to parse SSH private key: %s\n", keys[i].private)
delete(pairs) delete(pairs)
return return pairs, false
} }
ssh_pub, pub_ok := parse_ssh_public_key(keys[i].Public) ssh_pub, pub_ok := parse_ssh_public_key(keys[i].public)
if !pub_ok { if !pub_ok {
fmt.printf("Error: failed to parse SSH public key: %s\n", keys[i].Public) fmt.printf("Error: failed to parse SSH public key: %s\n", keys[i].public)
delete(pairs) delete(pairs)
return return pairs, false
} }
pk_rc := crypto_sign_ed25519_pk_to_curve25519(&pairs[i].Public[0], &ssh_pub[0]) pk_rc := crypto_sign_ed25519_pk_to_curve25519(&pairs[i].Public[0], &ssh_pub[0])
if pk_rc != 0 { if pk_rc != 0 {
fmt.println("Error: failed to convert ed25519 public key to curve25519") fmt.println("Error: failed to convert ed25519 public key to curve25519")
delete(pairs) delete(pairs)
return return pairs, false
} }
ed25519_sk: [64]u8 ed25519_sk: [64]u8
@@ -313,11 +316,10 @@ ssh_to_x25519 :: proc(keys: []SshKeyPair) -> (pairs: []X25519Keypair, ok: bool)
if sk_rc != 0 { if sk_rc != 0 {
fmt.println("Error: failed to convert ed25519 private key to curve25519") fmt.println("Error: failed to convert ed25519 private key to curve25519")
delete(pairs) delete(pairs)
return return pairs, false
} }
} }
ok = true return pairs, true
return
} }

View File

@@ -1,14 +1,16 @@
#+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/keys" CRYPTO_TEST_KEY_DIR :: "fixtures" + os.Path_Separator_String + "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)

501
db.odin
View File

@@ -1,10 +1,12 @@
package main package main
import "base:runtime"
import "core:crypto/hash" import "core:crypto/hash"
import "core:encoding/hex" import "core:encoding/hex"
import "core:encoding/ini" import "core:encoding/ini"
import "core:encoding/json" import "core:encoding/json"
import "core:fmt" import "core:fmt"
import "core:mem"
import "core:os" import "core:os"
import "core:path/filepath" import "core:path/filepath"
import "core:strings" import "core:strings"
@@ -12,90 +14,117 @@ import "core:strings"
import "sqlite" import "sqlite"
SyncFlagEnum :: enum { SyncFlagEnum :: enum {
Noop,
DirUpdated, DirUpdated,
Restored, Restored,
BackedUp, BackedUp,
Error,
} }
SyncFlag :: bit_set[SyncFlagEnum] SyncFlag :: bit_set[SyncFlagEnum]
SyncDirection :: enum { SyncError :: enum {
TrustDatabase, None,
TrustFilesystem, DirMissing,
MultipleDirs,
GitRootFailed,
WriteFailed,
ReadFailed,
DbFailed,
} }
Db :: struct { Db :: struct {
// Pointer to the sqlite db conn: sqlite.Db,
db: ^rawptr,
cfg: Config, cfg: Config,
changed: bool, changed: bool,
arena: mem.Dynamic_Arena,
} }
EnvFile :: struct { EnvFile :: struct {
Path: string, path: string,
Dir: string, dir: string,
Remotes: [dynamic]string, remotes: [dynamic]string,
Sha256: string, sha256: string,
contents: string, contents: string,
} }
@(deprecated = "call db_close to clean up EnvFiles")
delete_envfile :: proc(f: ^EnvFile) { delete_envfile :: proc(f: ^EnvFile) {
delete(f.Path) delete(f.path)
for &remote in f.Remotes { for &remote in f.remotes {
delete(remote) delete(remote)
} }
delete(f.Remotes) delete(f.remotes)
delete(f.Sha256) delete(f.sha256)
delete(f.contents) delete(f.contents)
} }
db_open :: proc(cfg_path: string) -> (database: Db, ok: bool) { db_open :: proc(cfg_path: string) -> (db: Db, ok: bool) {
database.cfg = load_config(cfg_path) or_return db = db_init() or_return
db.cfg = load_config(cfg_path, db_allocator(&db)) or_return
{ if len(db.cfg.keys) == 0 {
db: ^rawptr fmt.eprintf("Error: no SSH keys configured in %s\n", cfg_path)
rc := sqlite.db_open(":memory:", &db) db_close(&db)
return db, false
}
_, keys_ok := ssh_to_x25519(db.cfg.keys[:], context.temp_allocator)
if !keys_ok {
db_close(&db)
return db, false
}
// TODO: Use different allocators?
data_path := data_path(db.cfg.config_path, context.temp_allocator)
if os.exists(data_path) {
if ok = db_restore_from_encrypted(&db, data_path); !ok {
sqlite.close(db.conn)
return db, false
}
} else {
// DB was created
db.changed = true
}
return db, true
}
// Creates a database an allocator and fresh, empty table, with zero encryption.
// In production, you most likely want to use `db_open`.
db_init :: proc() -> (db: Db, ok: bool) {
conn: sqlite.Db
rc := sqlite.open(":memory:", &conn)
if rc != sqlite.OK { if rc != sqlite.OK {
fmt.printf("Error opening in-memory database: %s\n", sqlite.db_errmsg(db)) fmt.printf("Error opening in-memory database: %s\n", sqlite.errmsg(conn))
return return
} }
create_sql: cstring = "CREATE TABLE IF NOT EXISTS envr_env_files (path TEXT PRIMARY KEY NOT NULL, remotes TEXT, sha256 TEXT NOT NULL, contents TEXT NOT NULL)" 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) rc = sqlite.exec(conn, create_sql, nil, nil, nil)
if rc != sqlite.OK { if rc != sqlite.OK {
fmt.printf("Error creating table: %s\n", sqlite.db_errmsg(db)) fmt.printf("Error creating table: %s\n", sqlite.errmsg(conn))
sqlite.db_close(db) sqlite.close(conn)
return return
} }
database.db = db db.conn = conn
}
// TODO: Use different allocators? mem.dynamic_arena_init(&db.arena)
data_path := data_path(database.cfg.config_path, context.temp_allocator)
if os.exists(data_path) {
if ok = db_restore_from_encrypted(&database, data_path); !ok {
sqlite.db_close(database.db)
return
}
} else {
// DB was created
database.changed = true
}
return database, true return db, true
}
db_allocator :: proc(db: ^Db) -> mem.Allocator {
return mem.dynamic_arena_allocator(&db.arena)
} }
db_restore_from_encrypted :: proc(db: ^Db, data_path: string) -> bool { db_restore_from_encrypted :: proc(db: ^Db, data_path: string) -> bool {
encrypted_data, read_err := os.read_entire_file_from_path(data_path, context.allocator) encrypted_data, read_err := os.read_entire_file_from_path(data_path, context.temp_allocator)
defer delete(encrypted_data)
if read_err != nil { if read_err != nil {
fmt.printf("Error reading encrypted database: %v\n", read_err) fmt.printf("Error reading encrypted database: %v\n", read_err)
return false return false
} }
plaintext, dec_ok := decrypt(encrypted_data, db.cfg.Keys[:]) // TODO: Use context.temp_allocator
plaintext, dec_ok := decrypt(encrypted_data, db.cfg.keys[:])
if !dec_ok { if !dec_ok {
fmt.println("Error: decryption failed") fmt.println("Error: decryption failed")
return false return false
@@ -110,36 +139,40 @@ db_restore_from_encrypted :: proc(db: ^Db, data_path: string) -> bool {
} }
copy(buf[:len(plaintext)], plaintext) copy(buf[:len(plaintext)], plaintext)
rc := sqlite.deserialize( flags: sqlite.DESERIALIZE_FLAGS = {.FREEONCLOSE, .RESIZEABLE}
db.db,
"main", rc := sqlite.deserialize(db.conn, "main", buf, n, n, flags)
buf,
n,
n,
sqlite.DESERIALIZE_FREEONCLOSE | sqlite.DESERIALIZE_RESIZEABLE,
)
if rc != sqlite.OK { if rc != sqlite.OK {
sqlite.free(buf) sqlite.free(buf)
fmt.printf("Error deserializing database: %s\n", sqlite.db_errmsg(db.db)) fmt.printf("Error deserializing database: %s\n", sqlite.errmsg(db.conn))
return false return false
} }
return true return true
} }
db_close :: proc(d: ^Db) { // db_close will fail silently if cfg.keys is empty. If you want to save the
defer sqlite.db_close(d.db) // Db, be sure to use db_open rather than db_init
defer delete_config(&d.cfg) db_close :: proc(db: ^Db) {
allocator := db_allocator(db)
if d.changed { defer {
rc := sqlite.db_exec(d.db, "VACUUM", nil, nil, nil) sqlite.close(db.conn)
delete_config(&db.cfg, allocator)
mem.dynamic_arena_destroy(&db.arena)
}
if db.changed && len(db.cfg.keys) > 0 {
rc := sqlite.exec(db.conn, "VACUUM", nil, nil, nil)
if rc != sqlite.OK { if rc != sqlite.OK {
fmt.printf("Error vacuuming database: %s\n", sqlite.db_errmsg(d.db)) fmt.printf("Error vacuuming database: %s\n", sqlite.errmsg(db.conn))
return return
} }
sz: i64 sz: i64
data := sqlite.serialize(d.db, "main", &sz, 0) data := sqlite.serialize(db.conn, "main", &sz, 0)
if data == nil { if data == nil {
fmt.println("Error: failed to serialize database") fmt.println("Error: failed to serialize database")
return return
@@ -147,14 +180,16 @@ db_close :: proc(d: ^Db) {
defer sqlite.free(data) defer sqlite.free(data)
sqlite_data := data[:sz] sqlite_data := data[:sz]
encrypted, enc_ok := encrypt(sqlite_data, d.cfg.Keys[:]) // TODO: PAss allocator chain
encrypted, enc_ok := encrypt(sqlite_data, db.cfg.keys[:])
if !enc_ok { if !enc_ok {
fmt.println("Error: encryption failed") fmt.eprintln("Database encryption failed")
return return
} }
data_path := data_path(d.cfg.config_path) data_path := data_path(db.cfg.config_path, allocator)
envr_d := envr_dir(d.cfg.config_path) envr_d := envr_dir(db.cfg.config_path)
os.mkdir_all(envr_d) os.mkdir_all(envr_d)
write_err := os.write_entire_file(data_path, encrypted) write_err := os.write_entire_file(data_path, encrypted)
@@ -164,83 +199,89 @@ db_close :: proc(d: ^Db) {
return return
} }
d.changed = false db.changed = false
} }
} }
db_list :: proc(d: ^Db, allocator := context.allocator) -> (results: [dynamic]EnvFile, ok: bool) { // Results will be freed when `db_close` is called.
stmt: ^rawptr db_list :: proc(db: ^Db) -> ([]EnvFile, bool) {
stmt: sqlite.Stmt
rc := sqlite.prepare_v2( rc := sqlite.prepare_v2(
d.db, db.conn,
"SELECT path, remotes, sha256, contents FROM envr_env_files", "SELECT path, remotes, sha256, contents FROM envr_env_files",
-1, -1,
&stmt, &stmt,
nil, nil,
) )
if rc != sqlite.OK { if rc != sqlite.OK {
fmt.printf("Error preparing query: %s\n", sqlite.db_errmsg(d.db)) fmt.printf("Error preparing query: %s\n", sqlite.errmsg(db.conn))
return return []EnvFile{}, false
} }
defer sqlite.finalize(stmt) defer sqlite.finalize(stmt)
allocator := db_allocator(db)
results := make([dynamic]EnvFile, 0, 10, allocator)
for { for {
rc = sqlite.step(stmt) rc = sqlite.step(stmt)
if rc == sqlite.DONE { if rc == sqlite.DONE {
break break
} }
if rc != sqlite.ROW { if rc != sqlite.ROW {
fmt.printf("Error stepping query: %s\n", sqlite.db_errmsg(d.db)) fmt.printf("Error stepping query: %s\n", sqlite.errmsg(db.conn))
return #no_bounds_check return results[:], false
} }
remotes_json := string(sqlite.column_text(stmt, 1)) remotes_json := string(sqlite.column_text(stmt, 1))
remotes: [dynamic]string = --- remotes: [dynamic]string = ---
if len(remotes_json) > 0 { if len(remotes_json) > 0 {
json.unmarshal_string(remotes_json, &remotes, allocator = allocator) err := json.unmarshal_string(remotes_json, &remotes, allocator = allocator)
if err != nil {
fmt.eprintf("Warning: malformed remotes JSON: %v\n", err)
}
} }
path := clone_cstring(sqlite.column_text(stmt, 0), allocator) path := clone_cstring(sqlite.column_text(stmt, 0), allocator)
append( append(
&results, &results,
EnvFile { EnvFile {
Path = path, path = path,
Dir = filepath.dir(path), dir = filepath.dir(path),
Remotes = remotes, remotes = remotes,
Sha256 = clone_cstring(sqlite.column_text(stmt, 2), allocator), sha256 = clone_cstring(sqlite.column_text(stmt, 2), allocator),
contents = clone_cstring(sqlite.column_text(stmt, 3), allocator), contents = clone_cstring(sqlite.column_text(stmt, 3), allocator),
}, },
) )
} }
ok = true #no_bounds_check return results[:], true
return
} }
db_insert :: proc(d: ^Db, file: EnvFile) -> bool { // TODO: Should we use context.temp_allocator for proc scoped lifetimes?
remotes_json, marshal_err := json.marshal(file.Remotes) db_insert :: proc(db: ^Db, file: EnvFile) -> bool {
remotes_json, marshal_err := json.marshal(file.remotes, allocator = context.temp_allocator)
if marshal_err != nil { if marshal_err != nil {
fmt.printf("Error marshaling remotes: %v\n", marshal_err) fmt.printf("Error marshaling remotes: %v\n", marshal_err)
return false return false
} }
defer delete(remotes_json)
sql: cstring = sql: cstring =
"INSERT OR REPLACE INTO " + "INSERT OR REPLACE INTO " +
"envr_env_files (path, remotes, sha256, contents) VALUES (?, ?, ?, ?)" "envr_env_files (path, remotes, sha256, contents) VALUES (?, ?, ?, ?)"
stmt: ^rawptr stmt: sqlite.Stmt
rc := sqlite.prepare_v2(d.db, sql, -1, &stmt, nil) rc := sqlite.prepare_v2(db.conn, sql, -1, &stmt, nil)
if rc != sqlite.OK { if rc != sqlite.OK {
fmt.printf("Error preparing insert: %s\n", sqlite.db_errmsg(d.db)) fmt.printf("Error preparing insert: %s\n", sqlite.errmsg(db.conn))
return false return false
} }
defer sqlite.finalize(stmt) defer sqlite.finalize(stmt)
// TODO: deal with elsewhere? // TODO: deal with elsewhere?
cpath := to_cstring(file.Path) cpath := to_cstring(file.path)
defer delete(cpath) defer delete(cpath)
rc = sqlite.bind_text(stmt, 1, cpath, -1, nil) rc = sqlite.bind_text(stmt, 1, cpath, -1, nil)
if rc != sqlite.OK { if rc != sqlite.OK {
fmt.printf("Error binding path: %s\n", sqlite.db_errmsg(d.db)) fmt.printf("Error binding path: %s\n", sqlite.errmsg(db.conn))
return false return false
} }
@@ -248,15 +289,15 @@ db_insert :: proc(d: ^Db, file: EnvFile) -> bool {
defer delete(cremotes) defer delete(cremotes)
rc = sqlite.bind_text(stmt, 2, cremotes, -1, nil) rc = sqlite.bind_text(stmt, 2, cremotes, -1, nil)
if rc != sqlite.OK { if rc != sqlite.OK {
fmt.printf("Error binding remotes: %s\n", sqlite.db_errmsg(d.db)) fmt.printf("Error binding remotes: %s\n", sqlite.errmsg(db.conn))
return false return false
} }
csha := to_cstring(file.Sha256) csha := to_cstring(file.sha256)
defer delete(csha) defer delete(csha)
rc = sqlite.bind_text(stmt, 3, csha, -1, nil) rc = sqlite.bind_text(stmt, 3, csha, -1, nil)
if rc != sqlite.OK { if rc != sqlite.OK {
fmt.printf("Error binding sha256: %s\n", sqlite.db_errmsg(d.db)) fmt.printf("Error binding sha256: %s\n", sqlite.errmsg(db.conn))
return false return false
} }
@@ -264,35 +305,42 @@ db_insert :: proc(d: ^Db, file: EnvFile) -> bool {
defer delete(ccontents) defer delete(ccontents)
rc = sqlite.bind_text(stmt, 4, ccontents, -1, nil) rc = sqlite.bind_text(stmt, 4, ccontents, -1, nil)
if rc != sqlite.OK { if rc != sqlite.OK {
fmt.printf("Error binding contents: %s\n", sqlite.db_errmsg(d.db)) fmt.printf("Error binding contents: %s\n", sqlite.errmsg(db.conn))
return false return false
} }
rc = sqlite.step(stmt) rc = sqlite.step(stmt)
if rc != sqlite.DONE { if rc != sqlite.DONE {
fmt.printf("Error inserting: %s\n", sqlite.db_errmsg(d.db)) fmt.printf("Error inserting: %s\n", sqlite.errmsg(db.conn))
return false return false
} }
d.changed = true db.changed = true
return true return true
} }
db_fetch :: proc(d: ^Db, path: string, allocator := context.allocator) -> (EnvFile, bool) { // Result will be freed when `db_close` is called.
//
// Expects an absolute path
db_fetch :: proc(db: ^Db, path: string) -> (EnvFile, bool) {
assert(os.is_absolute_path(path))
sql: cstring = "SELECT path, remotes, sha256, contents FROM envr_env_files WHERE path = ?" sql: cstring = "SELECT path, remotes, sha256, contents FROM envr_env_files WHERE path = ?"
stmt: ^rawptr stmt: sqlite.Stmt
rc := sqlite.prepare_v2(d.db, sql, -1, &stmt, nil) rc := sqlite.prepare_v2(db.conn, sql, -1, &stmt, nil)
if rc != sqlite.OK { if rc != sqlite.OK {
fmt.printf("Error preparing fetch: %s\n", sqlite.db_errmsg(d.db)) fmt.printf("Error preparing fetch: %s\n", sqlite.errmsg(db.conn))
return EnvFile{}, false return EnvFile{}, false
} }
defer sqlite.finalize(stmt) defer sqlite.finalize(stmt)
allocator := db_allocator(db)
cpath := to_cstring(path, allocator) cpath := to_cstring(path, allocator)
defer delete(cpath, allocator) defer delete(cpath, allocator)
rc = sqlite.bind_text(stmt, 1, cpath, -1, nil) rc = sqlite.bind_text(stmt, 1, cpath, -1, nil)
if rc != sqlite.OK { if rc != sqlite.OK {
fmt.printf("Error binding path: %s\n", sqlite.db_errmsg(d.db)) fmt.printf("Error binding path: %s\n", sqlite.errmsg(db.conn))
return EnvFile{}, false return EnvFile{}, false
} }
rc = sqlite.step(stmt) rc = sqlite.step(stmt)
@@ -301,34 +349,37 @@ db_fetch :: proc(d: ^Db, path: string, allocator := context.allocator) -> (EnvFi
return EnvFile{}, false return EnvFile{}, false
} }
if rc != sqlite.ROW { if rc != sqlite.ROW {
fmt.printf("Error fetching: %s\n", sqlite.db_errmsg(d.db)) fmt.printf("Error fetching: %s\n", sqlite.errmsg(db.conn))
return EnvFile{}, false return EnvFile{}, false
} }
remotes_json := string(sqlite.column_text(stmt, 1)) remotes_json := string(sqlite.column_text(stmt, 1))
remotes: [dynamic]string = --- remotes: [dynamic]string = ---
if len(remotes_json) > 0 { if len(remotes_json) > 0 {
json.unmarshal_string(remotes_json, &remotes, allocator = allocator) err := json.unmarshal_string(remotes_json, &remotes, allocator = allocator)
if err != nil {
fmt.eprintf("Warning: malformed remotes JSON: %v\n", err)
}
} }
file_path := clone_cstring(sqlite.column_text(stmt, 0)) file_path := clone_cstring(sqlite.column_text(stmt, 0), allocator)
return EnvFile { return EnvFile {
Path = file_path, path = file_path,
Dir = filepath.dir(file_path), dir = filepath.dir(file_path),
Remotes = remotes, remotes = remotes,
Sha256 = clone_cstring(sqlite.column_text(stmt, 2), allocator), sha256 = clone_cstring(sqlite.column_text(stmt, 2), allocator),
contents = clone_cstring(sqlite.column_text(stmt, 3), allocator), contents = clone_cstring(sqlite.column_text(stmt, 3), allocator),
}, },
true true
} }
db_delete :: proc(d: ^Db, path: string) -> bool { db_delete :: proc(db: ^Db, path: string) -> bool {
sql: cstring = "DELETE FROM envr_env_files WHERE path = ?" sql: cstring = "DELETE FROM envr_env_files WHERE path = ?"
stmt: ^rawptr stmt: sqlite.Stmt
rc := sqlite.prepare_v2(d.db, sql, -1, &stmt, nil) rc := sqlite.prepare_v2(db.conn, sql, -1, &stmt, nil)
if rc != sqlite.OK { if rc != sqlite.OK {
fmt.printf("Error preparing delete: %s\n", sqlite.db_errmsg(d.db)) fmt.printf("Error preparing delete: %s\n", sqlite.errmsg(db.conn))
return false return false
} }
defer sqlite.finalize(stmt) defer sqlite.finalize(stmt)
@@ -337,24 +388,25 @@ db_delete :: proc(d: ^Db, path: string) -> bool {
defer delete(cpath) defer delete(cpath)
rc = sqlite.bind_text(stmt, 1, cpath, -1, nil) rc = sqlite.bind_text(stmt, 1, cpath, -1, nil)
if rc != sqlite.OK { if rc != sqlite.OK {
fmt.printf("Error binding path: %s\n", sqlite.db_errmsg(d.db)) fmt.printf("Error binding path: %s\n", sqlite.errmsg(db.conn))
return false return false
} }
rc = sqlite.step(stmt) rc = sqlite.step(stmt)
if rc != sqlite.DONE { if rc != sqlite.DONE {
fmt.printf("Error deleting: %s\n", sqlite.db_errmsg(d.db)) fmt.printf("Error deleting: %s\n", sqlite.errmsg(db.conn))
return false return false
} }
if sqlite.changes(d.db) == 0 { if sqlite.changes(db.conn) == 0 {
fmt.printf("No file found with path: %s\n", path) fmt.printf("No file found with path: %s\n", path)
return false return false
} }
d.changed = true db.changed = true
return true return true
} }
// Caller is responsible for the returned memory
new_env_file :: proc(path: string) -> (EnvFile, bool) { new_env_file :: proc(path: string) -> (EnvFile, bool) {
abs_path, abs_err := filepath.abs(path) abs_path, abs_err := filepath.abs(path)
if abs_err != nil { if abs_err != nil {
@@ -364,153 +416,127 @@ new_env_file :: proc(path: string) -> (EnvFile, bool) {
dir := filepath.dir(abs_path) dir := filepath.dir(abs_path)
remotes := get_git_remotes(dir) // TODO: Should we use the db allocator here?
remotes := get_git_remotes(dir, context.allocator)
data, read_err := os.read_entire_file_from_path(abs_path, context.allocator) data, read_err := os.read_entire_file_from_path(abs_path, context.allocator)
defer delete(data)
if read_err != nil { if read_err != nil {
fmt.printf("Error reading file %s: %v\n", abs_path, read_err) fmt.printf("Error reading file %s: %v\n", abs_path, read_err)
return EnvFile{}, false return EnvFile{}, false
} }
digest := hash.hash_bytes(hash.Algorithm.SHA256, data, context.temp_allocator) digest := hash.hash_bytes(hash.Algorithm.SHA256, data, context.temp_allocator)
// TODO: Handle error hex_bytes := hex.encode(digest, context.allocator)
hex_bytes, _ := hex.encode(digest)
return EnvFile { return EnvFile {
Path = abs_path, path = abs_path,
Dir = dir, dir = dir,
Remotes = remotes, remotes = remotes,
Sha256 = string(hex_bytes), sha256 = string(hex_bytes),
contents = string(data), contents = string(data),
}, },
true true
} }
db_sync :: proc(d: ^Db, f: ^EnvFile) -> (SyncFlag, string) { // Reconciles `f` with the filesystem and persists changes to the database.
return env_file_sync(f, .TrustFilesystem, d) db_sync :: proc(db: ^Db, f: ^EnvFile) -> (SyncFlag, SyncError) {
} allocator := db_allocator(db)
// If SyncFlag is .BackedUp, Caller is responsible for calling delete on f.contents and f.Sha256
env_file_sync :: proc(f: ^EnvFile, dir: SyncDirection, d: ^Db) -> (SyncFlag, string) {
result: SyncFlag = {} result: SyncFlag = {}
old_path := f.path
_, stat_err := os.stat(f.Dir, context.allocator) if !os.exists(f.dir) {
if stat_err != nil { moved, err := try_move_dir(db, f, allocator)
moved_dirs: [dynamic]string if !moved {
return {}, err
if d != nil {
dirs, dirs_ok := find_moved_dirs(d, f)
if !dirs_ok {
return {.Error}, "failed to find moved dirs"
} }
moved_dirs = dirs result += {.DirUpdated}
} }
if len(moved_dirs) == 0 { if !os.exists(f.path) {
return {.Error}, "directory missing" write_err := os.write_entire_file(f.path, f.contents)
} else if len(moved_dirs) == 1 {
update_dir(f, moved_dirs[0])
result = {.DirUpdated}
} else {
return {.Error}, "multiple directories found"
}
}
_, file_stat_err := os.stat(f.Path, context.allocator)
if file_stat_err != nil {
write_err := os.write_entire_file(f.Path, f.contents)
if write_err != nil { if write_err != nil {
msg, _ := strings.concatenate({"failed to write file: ", fmt.tprintf("%v", write_err)}) fmt.eprintf("db_sync: failed to write %s: %v\n", f.path, write_err)
return {.Error}, msg return result, .WriteFailed
} }
return result + {.Restored}, "" if !db_persist(db, f, old_path) {
return result, .DbFailed
}
return result + {.Restored}, .None
} }
data, read_err := os.read_entire_file_from_path(f.Path, context.allocator) data, read_err := os.read_entire_file_from_path(f.path, allocator)
if read_err != nil { if read_err != nil {
msg, _ := strings.concatenate( fmt.eprintf("db_sync: failed to read %s: %v\n", f.path, read_err)
{"failed to read file for SHA comparison: ", fmt.tprintf("%v", read_err)}, return result, .ReadFailed
)
return {.Error}, msg
} }
digest := hash.hash_bytes(hash.Algorithm.SHA256, data) digest := hash.hash_bytes(hash.Algorithm.SHA256, data, context.temp_allocator)
// TODO: Handle error hex_bytes := hex.encode(digest, allocator)
hex_bytes, _ := hex.encode(digest)
current_sha := string(hex_bytes) current_sha := string(hex_bytes)
if current_sha == f.Sha256 { if current_sha == f.sha256 {
return result, "" if !db_persist(db, f, old_path) {
return result, .DbFailed
} }
return result, .None
switch dir {
case .TrustDatabase:
write_err := os.write_entire_file(f.Path, f.contents)
if write_err != nil {
msg, _ := strings.concatenate({"failed to write file: ", fmt.tprintf("%v", write_err)})
return {.Error}, msg
}
return result + {.Restored}, ""
case .TrustFilesystem:
if !env_file_backup(f) {
return {.Error}, "failed to backup file"
}
return result + {.BackedUp}, ""
}
return result, ""
}
find_moved_dirs :: proc(d: ^Db, f: ^EnvFile) -> ([dynamic]string, bool) {
roots, roots_ok := find_git_roots(d.cfg)
if !roots_ok {
return {}, false
}
moved: [dynamic]string
for root in roots {
remotes := get_git_remotes(root)
if shares_remote(f, remotes[:]) {
cloned, _ := strings.clone(root)
append(&moved, cloned)
}
}
return moved, true
}
update_dir :: proc(f: ^EnvFile, new_dir: string) {
f.Dir = new_dir
base := filepath.base(f.Path)
new_path, _ := strings.concatenate({new_dir, "/", base})
f.Path = new_path
f.Remotes = get_git_remotes(new_dir)
}
// Loads the contents of the the file at f.Path into f.contents
//
// Caller is responsible for calling delete on f.contents and f.Sha256
env_file_backup :: proc(f: ^EnvFile) -> bool {
data, read_err := os.read_entire_file_from_path(f.Path, context.allocator)
if read_err != nil {
fmt.printf("Error reading file %s: %v\n", f.Path, read_err)
return false
} }
f.contents = string(data) f.contents = string(data)
digest := hash.hash_bytes(hash.Algorithm.SHA256, data, context.temp_allocator) f.sha256 = current_sha
hex_bytes, alloc_err := hex.encode(digest) if !db_persist(db, f, old_path) {
if alloc_err != nil { return result, .DbFailed
fmt.printf("Error generating hash for file %s: %v\n", f.Path, alloc_err) }
return result + {.BackedUp}, .None
}
db_persist :: proc(db: ^Db, f: ^EnvFile, old_path: string) -> bool {
if f.path != old_path {
if !db_delete(db, old_path) {
return false return false
} }
f.Sha256 = string(hex_bytes) }
return true return db_insert(db, f^)
}
try_move_dir :: proc(db: ^Db, f: ^EnvFile, allocator: mem.Allocator) -> (bool, SyncError) {
roots, ok := find_git_roots(db.cfg)
if !ok {
return false, .GitRootFailed
}
defer {
for root in roots {
delete(root)
}
delete(roots)
}
match_count := 0
matched_dir: string
for root in roots {
remotes := get_git_remotes(root, context.temp_allocator)
if shares_remote(f, remotes[:]) {
match_count += 1
matched_dir = root
}
}
switch match_count {
case 0:
return false, .DirMissing
case 1:
f.dir, _ = strings.clone(matched_dir, allocator)
base := filepath.base(f.path)
new_path, _ := filepath.join({f.dir, base}, allocator)
f.path = new_path
f.remotes = get_git_remotes(f.dir, allocator)
return true, .None
case:
return false, .MultipleDirs
}
} }
shares_remote :: proc(f: ^EnvFile, remotes: []string) -> bool { shares_remote :: proc(f: ^EnvFile, remotes: []string) -> bool {
for r1 in f.Remotes { for r1 in f.remotes {
for r2 in remotes { for r2 in remotes {
if r1 == r2 { if r1 == r2 {
return true return true
@@ -520,38 +546,35 @@ shares_remote :: proc(f: ^EnvFile, remotes: []string) -> bool {
return false return false
} }
get_git_remotes :: proc(dir: string) -> [dynamic]string { get_git_remotes :: proc(dir: string, allocator: mem.Allocator) -> [dynamic]string {
remotes: [dynamic]string
remote_set: map[string]bool
defer delete(remote_set)
config_path, _ := filepath.join({dir, ".git", "config"}, context.temp_allocator) config_path, _ := filepath.join({dir, ".git", "config"}, context.temp_allocator)
m, _, ok := ini.load_map_from_path(config_path, context.allocator) // TODO: Handle error
if !ok { m, _, read_ok := ini.load_map_from_path(config_path, context.temp_allocator)
return remotes if !read_ok {
return nil
} }
defer ini.delete_map(m)
remotes := make([dynamic]string, 0, 1, allocator)
for section_name, section in m { for section_name, section in m {
if strings.has_prefix(section_name, "remote ") { if strings.has_prefix(section_name, "remote ") {
if url, ok := section["url"]; ok { if url, ok := section["url"]; ok {
remote_set[url] = true found := false
for r in remotes {
if r == url {found = true; break}
} }
} if !found {
} // FIXME: Currently leaks when adding a file with envr scan
cloned, _ := strings.clone(url, allocator)
for remote in remote_set {
cloned, _ := strings.clone(remote)
append(&remotes, cloned) append(&remotes, cloned)
} }
}
}
}
return remotes return remotes
} }
db_update_required :: proc(status: SyncFlag) -> bool {
return .BackedUp in status || .DirUpdated in status
}
to_cstring :: proc { to_cstring :: proc {
string_to_cstring, string_to_cstring,
strings.to_cstring, strings.to_cstring,
@@ -566,7 +589,7 @@ string_to_cstring :: proc(s: string, allocator := context.allocator) -> cstring
return cs return cs
} }
// Caller is responsible for freeing the result // Unless an explicit allocator is passed, caller is responsible for freeing the result
clone_cstring :: proc(c: cstring, allocator := context.allocator) -> string { clone_cstring :: proc(c: cstring, allocator := context.allocator) -> string {
str, err := strings.clone_from_cstring(c, allocator) str, err := strings.clone_from_cstring(c, allocator)
if err != nil { if err != nil {

View File

@@ -1,3 +1,4 @@
#+test
package main package main
import "core:fmt" import "core:fmt"
@@ -10,6 +11,14 @@ 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"},
@@ -19,7 +28,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 {
@@ -29,9 +38,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
} }
@@ -39,7 +48,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()
@@ -50,7 +59,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
@@ -63,7 +72,7 @@ test_encrypt_decrypt_sqlite_roundtrip :: proc(t: ^testing.T) {
testing.expect(t, encrypted[2] == u8('V'), "magic byte 2") testing.expect(t, encrypted[2] == u8('V'), "magic byte 2")
testing.expect(t, encrypted[3] == u8('R'), "magic byte 3") 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
@@ -92,7 +101,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()
@@ -103,20 +112,21 @@ 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)
tmp_enc_path := fmt.tprintf("/tmp/envr-test-ewrd-%d.envr", os.get_pid()) ewrd_dir := test_temp_dir(t, "envr-test-ewrd-*")
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)
@@ -125,7 +135,7 @@ 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
@@ -139,7 +149,7 @@ test_encrypt_write_read_decrypt :: proc(t: ^testing.T) {
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()
@@ -150,27 +160,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: ^rawptr mem_db: sqlite.Db
rc := sqlite.db_open(":memory:", &mem_db) rc := sqlite.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.db_close(mem_db) defer sqlite.close(mem_db)
n := i64(len(plaintext)) n := i64(len(plaintext))
buf := sqlite.malloc64(n) buf := sqlite.malloc64(n)
@@ -178,14 +188,7 @@ 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( rc = sqlite.deserialize(mem_db, "main", buf, n, n, {.FREEONCLOSE, .RESIZEABLE})
mem_db,
"main",
buf,
n,
n,
sqlite.DESERIALIZE_FREEONCLOSE | sqlite.DESERIALIZE_RESIZEABLE,
)
testing.expect(t, rc == sqlite.OK, "deserialize should succeed") testing.expect(t, rc == sqlite.OK, "deserialize should succeed")
if rc != sqlite.OK { if rc != sqlite.OK {
sqlite.free(buf) sqlite.free(buf)
@@ -193,7 +196,7 @@ test_decrypt_then_deserialize_sqlite :: proc(t: ^testing.T) {
} }
sql: cstring = "SELECT path FROM envr_env_files" sql: cstring = "SELECT path FROM envr_env_files"
stmt: ^rawptr stmt: sqlite.Stmt
rc = sqlite.prepare_v2(mem_db, sql, -1, &stmt, nil) rc = sqlite.prepare_v2(mem_db, sql, -1, &stmt, nil)
testing.expect(t, rc == sqlite.OK, "prepare failed") testing.expect(t, rc == sqlite.OK, "prepare failed")
if rc != sqlite.OK { if rc != sqlite.OK {
@@ -212,7 +215,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)
@@ -222,18 +225,22 @@ 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)
envr_dir_path := fmt.tprintf("/tmp/envr-test-cycle-%d/.envr", os.get_pid()) cycle_dir := test_temp_dir(t, "envr-test-cycle-*")
os.mkdir_all(envr_dir_path) defer os.remove_all(cycle_dir)
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"}) data_path, _ := filepath.join([]string{envr_dir_path, "data.envr"}, context.temp_allocator)
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 {
@@ -247,21 +254,21 @@ 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
@@ -288,13 +295,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
@@ -309,7 +316,6 @@ test_ssh_key_parse_from_fixtures :: proc(t: ^testing.T) {
if !x_ok { if !x_ok {
return return
} }
defer delete(x25519_pairs)
testing.expect(t, len(x25519_pairs) == 1, "should have 1 x25519 keypair") testing.expect(t, len(x25519_pairs) == 1, "should have 1 x25519 keypair")
} }
@@ -318,20 +324,20 @@ test_ssh_key_parse_from_fixtures :: proc(t: ^testing.T) {
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(t, len(cfg.Keys) == 1, "should have 1 key") 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,5 +1,8 @@
#+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"
@@ -8,225 +11,192 @@ 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)), remotes = make([dynamic]string, 0, len(remotes), context.temp_allocator),
} }
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) {
d, ok := make_test_db() db, ok := db_init()
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 sqlite.db_close(d.db) defer db_close(&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(&d, f), "insert should succeed") testing.expect(t, db_insert(&db, f), "insert should succeed")
fetched, fetch_ok := db_fetch(&d, "/project/.env") fetched, fetch_ok := db_fetch(&db, "/project/.env")
defer delete_envfile(&fetched) // 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) {
d, ok := make_test_db() db, ok := db_init()
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 sqlite.db_close(d.db) defer db_close(&db)
_, fetch_ok := db_fetch(&d, "/nonexistent/.env") _, fetch_ok := db_fetch(&db, "/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) {
d, ok := make_test_db() db, ok := db_init()
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(&d, f1), "first insert should succeed") testing.expect(t, db_insert(&db, 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(&d, f2), "second insert should succeed") testing.expect(t, db_insert(&db, f2), "second insert should succeed")
results, list_ok := db_list(&d) results, list_ok := db_list(&db)
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(t, len(results) == 1, "should have 1 row, not 2") testing.expect(t, len(results) == 1, "should have 1 row, not 2")
fetched, fetch_ok := db_fetch(&d, "/project/.env") fetched, fetch_ok := db_fetch(&db, "/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) {
d, ok := make_test_db() db, ok := db_init()
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 sqlite.db_close(d.db) defer db_close(&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(&d, f) db_insert(&db, f)
testing.expect(t, db_delete(&d, "/project/.env"), "delete should return true") testing.expect(t, db_delete(&db, "/project/.env"), "delete should return true")
_, fetch_ok := db_fetch(&d, "/project/.env") _, fetch_ok := db_fetch(&db, "/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) {
d, ok := make_test_db() db, ok := db_init()
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 sqlite.db_close(d.db) defer db_close(&db)
testing.expect(t, !db_delete(&d, "/nonexistent/.env"), "delete missing should return false") testing.expect(t, !db_delete(&db, "/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) {
d, ok := make_test_db() db, ok := db_init()
testing.expect(t, ok, "failed to create test db") testing.expect(t, ok, "failed to create test db")
if !ok do return defer db_close(&db)
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(&d, f1) db_insert(&db, f1)
db_insert(&d, f2) db_insert(&db, f2)
db_insert(&d, f3) db_insert(&db, f3)
results, list_ok := db_list(&d) results, list_ok := db_list(&db)
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) {
d, ok := make_test_db() db, ok := db_init()
testing.expect(t, ok, "failed to create test db") testing.expect(t, ok, "failed to create test db")
if !ok do return defer db_close(&db)
defer sqlite.db_close(d.db)
results, list_ok := db_list(&d) results, list_ok := db_list(&db)
testing.expect(t, list_ok, "list should succeed on empty db") testing.expect(t, list_ok, "list should succeed on empty db")
testing.expect(t, len(results) == 0, "should have 0 rows") 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) {
d, ok := make_test_db() db, ok := db_init()
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 sqlite.db_close(d.db) defer db_close(&db)
testing.expect(t, !d.changed, "changed should start false") testing.expect(t, !db.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(&d, f) db_insert(&db, f)
testing.expect(t, d.changed, "changed should be true after insert") testing.expect(t, db.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) {
d, ok := make_test_db() db, ok := db_init()
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 sqlite.db_close(d.db) defer db_close(&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(&d, f) db_insert(&db, f)
d.changed = false db.changed = false
db_delete(&d, "/project/.env") db_delete(&db, "/project/.env")
testing.expect(t, d.changed, "changed should be true after delete") testing.expect(t, db.changed, "changed should be true after delete")
} }
@(test) @(test)
test_db_serialize :: proc(t: ^testing.T) { test_db_serialize :: proc(t: ^testing.T) {
d, ok := make_test_db() db, ok := db_init()
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 sqlite.db_close(d.db) defer db_close(&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(&d, f) db_insert(&db, f)
sz: i64 sz: i64
data := sqlite.serialize(d.db, "main", &sz, 0) data := sqlite.serialize(db.conn, "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)
@@ -234,44 +204,13 @@ 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")
@@ -280,9 +219,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")
@@ -291,7 +230,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"}
@@ -301,9 +240,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")
@@ -312,7 +251,7 @@ 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
@@ -328,8 +267,7 @@ delete_remotes :: proc(remotes: [dynamic]string) {
@(test) @(test)
test_get_git_remotes_single :: proc(t: ^testing.T) { test_get_git_remotes_single :: proc(t: ^testing.T) {
base := fmt.tprintf("/tmp/envr-test-remotes-%d", os.get_pid()) base := test_temp_dir(t, "envr-test-remotes-*")
os.mkdir_all(base)
defer os.remove_all(base) defer os.remove_all(base)
git_dir := fmt.tprintf("%s/.git", base) git_dir := fmt.tprintf("%s/.git", base)
@@ -340,8 +278,7 @@ test_get_git_remotes_single :: proc(t: ^testing.T) {
err := os.write_entire_file(config_path, transmute([]u8)config_content) err := os.write_entire_file(config_path, transmute([]u8)config_content)
testing.expect(t, err == nil, "should write .git/config") testing.expect(t, err == nil, "should write .git/config")
remotes := get_git_remotes(base) remotes := get_git_remotes(base, context.temp_allocator)
defer delete_remotes(remotes)
testing.expect(t, len(remotes) == 1, "should find 1 remote") testing.expect(t, len(remotes) == 1, "should find 1 remote")
if len(remotes) != 1 do return if len(remotes) != 1 do return
@@ -350,8 +287,7 @@ test_get_git_remotes_single :: proc(t: ^testing.T) {
@(test) @(test)
test_get_git_remotes_multiple :: proc(t: ^testing.T) { test_get_git_remotes_multiple :: proc(t: ^testing.T) {
base := fmt.tprintf("/tmp/envr-test-remotes-multi-%d", os.get_pid()) base := test_temp_dir(t, "envr-test-remotes-multi-*")
os.mkdir_all(base)
defer os.remove_all(base) defer os.remove_all(base)
git_dir := fmt.tprintf("%s/.git", base) git_dir := fmt.tprintf("%s/.git", base)
@@ -362,28 +298,24 @@ test_get_git_remotes_multiple :: proc(t: ^testing.T) {
err := os.write_entire_file(config_path, transmute([]u8)config_content) err := os.write_entire_file(config_path, transmute([]u8)config_content)
testing.expect(t, err == nil, "should write .git/config") testing.expect(t, err == nil, "should write .git/config")
remotes := get_git_remotes(base) remotes := get_git_remotes(base, context.temp_allocator)
defer delete_remotes(remotes)
testing.expect(t, len(remotes) == 2, "should find 2 remotes") testing.expect(t, len(remotes) == 2, "should find 2 remotes")
} }
@(test) @(test)
test_get_git_remotes_no_config :: proc(t: ^testing.T) { test_get_git_remotes_no_config :: proc(t: ^testing.T) {
base := fmt.tprintf("/tmp/envr-test-remotes-none-%d", os.get_pid()) base := test_temp_dir(t, "envr-test-remotes-none-*")
os.mkdir_all(base)
defer os.remove_all(base) defer os.remove_all(base)
remotes := get_git_remotes(base) remotes := get_git_remotes(base, context.temp_allocator)
defer delete_remotes(remotes)
testing.expect(t, len(remotes) == 0, "should return empty when no .git/config") testing.expect(t, len(remotes) == 0, "should return empty when no .git/config")
} }
@(test) @(test)
test_get_git_remotes_no_remotes :: proc(t: ^testing.T) { test_get_git_remotes_no_remotes :: proc(t: ^testing.T) {
base := fmt.tprintf("/tmp/envr-test-remotes-empty-%d", os.get_pid()) base := test_temp_dir(t, "envr-test-remotes-empty-*")
os.mkdir_all(base)
defer os.remove_all(base) defer os.remove_all(base)
git_dir := fmt.tprintf("%s/.git", base) git_dir := fmt.tprintf("%s/.git", base)
@@ -394,16 +326,14 @@ test_get_git_remotes_no_remotes :: proc(t: ^testing.T) {
err := os.write_entire_file(config_path, transmute([]u8)config_content) err := os.write_entire_file(config_path, transmute([]u8)config_content)
testing.expect(t, err == nil, "should write .git/config") testing.expect(t, err == nil, "should write .git/config")
remotes := get_git_remotes(base) remotes := get_git_remotes(base, context.temp_allocator)
defer delete_remotes(remotes)
testing.expect(t, len(remotes) == 0, "should return empty when no remote sections") testing.expect(t, len(remotes) == 0, "should return empty when no remote sections")
} }
@(test) @(test)
test_new_env_file :: proc(t: ^testing.T) { test_new_env_file :: proc(t: ^testing.T) {
base := fmt.tprintf("/tmp/envr-test-envfile-%d", os.get_pid()) base := test_temp_dir(t, "envr-test-envfile-*")
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)
@@ -413,14 +343,15 @@ test_new_env_file :: proc(t: ^testing.T) {
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.Remotes) defer delete(file.contents)
defer delete(file.Sha256) defer delete(file.remotes)
defer delete(file.Path) defer delete(file.sha256)
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(t, file.contents == "SECRET=value\n", "contents mismatch") testing.expect(t, file.contents == "SECRET=value\n", "contents mismatch")
testing.expect(t, len(file.Sha256) == 64, "sha256 should be 64 hex chars") testing.expect(t, len(file.sha256) == 64, "sha256 should be 64 hex chars")
} }
@(test) @(test)
@@ -429,87 +360,50 @@ test_new_env_file_missing :: proc(t: ^testing.T) {
testing.expect(t, !ok, "missing file should return false") testing.expect(t, !ok, "missing file should return false")
} }
@(test)
test_env_file_backup :: proc(t: ^testing.T) {
base := fmt.tprintf("/tmp/envr-test-backup-%d", os.get_pid())
os.mkdir_all(base)
defer os.remove_all(base)
env_path := fmt.tprintf("%s/.env", base)
err := os.write_entire_file(env_path, "KEY=12345\n")
testing.expect(t, err == nil, ".env file should exist")
f := EnvFile {
Path = env_path,
}
defer delete(f.contents)
defer delete(f.Sha256)
testing.expect(t, env_file_backup(&f), "backup should succeed")
testing.expect_value(t, f.contents, "KEY=12345\n")
testing.expect_value(t, len(f.Sha256), 64)
}
@(test)
test_env_file_backup_missing :: proc(t: ^testing.T) {
f := EnvFile {
Path = "/tmp/envr-nonexistent-backup/.env",
}
testing.expect(t, !env_file_backup(&f), "missing file should return false")
}
@(test)
test_update_dir :: proc(t: ^testing.T) {
f := EnvFile {
Path = "/old/project/.env",
Dir = "/old/project",
Remotes = make([dynamic]string, 0),
}
defer delete_envfile(&f)
update_dir(&f, "/new/location")
testing.expect_value(t, f.Dir, "/new/location")
testing.expect_value(t, f.Path, "/new/location/.env")
}
@(test) @(test)
test_closing_db_has_no_leaks :: proc(t: ^testing.T) { test_closing_db_has_no_leaks :: proc(t: ^testing.T) {
base := fmt.tprintf("/tmp/envr-test-leak-%d", os.get_pid()) base := test_temp_dir(t, "envr-test-leak-*")
os.mkdir_all(base)
defer os.remove_all(base) defer os.remove_all(base)
cfg_path, err := filepath.join([]string{base, "config.json"}, context.temp_allocator) cfg_path, err := filepath.join([]string{base, "config.json"}, context.temp_allocator)
testing.expect(t, err == nil, "cfgPath should build successfully") testing.expect(t, err == nil, "cfgPath should build successfully")
{
cfg := new_config([]string{"fixtures/keys/insecure-test-key"}, cfg_path) cfg := new_config([]string{"fixtures/keys/insecure-test-key"}, cfg_path)
defer delete_config(&cfg)
testing.expect(t, save_config(cfg, force = true), "save should succeed") testing.expect(t, save_config(cfg, force = true), "save should succeed")
delete_config(&cfg)
}
db, ok := db_open(cfg_path) db, ok := db_open(cfg_path)
testing.expect(t, ok, "db should open") testing.expect(t, ok, "db should open")
if !ok do return
db_close(&db) db_close(&db)
} }
@(test) @(test)
test_open_existing_db_has_no_leaks :: proc(t: ^testing.T) { test_open_existing_db_has_no_leaks :: proc(t: ^testing.T) {
base := fmt.tprintf("/tmp/envr-test-leak-existing-%d", os.get_pid()) base := test_temp_dir(t, "envr-test-leak-existing-*")
os.mkdir_all(base)
defer os.remove_all(base) defer os.remove_all(base)
cfg_path, err := filepath.join([]string{base, "config.json"}, context.temp_allocator) cfg_path, err := filepath.join([]string{base, "config.json"}, context.temp_allocator)
testing.expect(t, err == nil, "cfgPath should build successfully") testing.expect(t, err == nil, "cfgPath should build successfully")
{
cfg := new_config([]string{"fixtures/keys/insecure-test-key"}, cfg_path) cfg := new_config([]string{"fixtures/keys/insecure-test-key"}, cfg_path)
defer delete_config(&cfg)
testing.expect(t, save_config(cfg, force = true), "save should succeed") testing.expect(t, save_config(cfg, force = true), "save should succeed")
delete_config(&cfg)
}
// First open/close creates data.envr on disk // First open/close creates data.envr on disk
db, ok := db_open(cfg_path) db, ok := db_open(cfg_path)
testing.expect(t, ok, "db should open") testing.expect(t, ok, "db should open")
if !ok do return if !ok do return
f := make_test_env_file("/project/.env", "abc123", "SECRET=value", []string{"git@github.com:user/repo.git"}) f := make_test_env_file(
defer delete(f.Remotes) "/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") testing.expect(t, db_insert(&db, f), "insert should succeed")
db_close(&db) db_close(&db)
@@ -520,3 +414,148 @@ test_open_existing_db_has_no_leaks :: proc(t: ^testing.T) {
db_close(&db2) db_close(&db2)
} }
@(test)
test_db_sync_noop :: proc(t: ^testing.T) {
base := test_temp_dir(t, "envr-test-sync-noop-*")
defer os.remove_all(base)
env_path := fmt.tprintf("%s/.env", base)
content := "KEY=value\n"
write_err := os.write_entire_file(env_path, transmute([]u8)content)
testing.expect(t, write_err == nil, "should write .env file")
digest := hash.hash_bytes(
hash.Algorithm.SHA256,
transmute([]u8)content,
context.temp_allocator,
)
hex_bytes := hex.encode(digest, context.temp_allocator)
sha := string(hex_bytes)
db, ok := db_init()
testing.expect(t, ok, "failed to create test db")
defer db_close(&db)
f := make_test_env_file(env_path, sha, content)
f.dir = base
db_insert(&db, f)
result, sync_err := db_sync(&db, &f)
testing.expect(t, sync_err == .None, "sync should not error")
testing.expect(t, result == {}, "should be noop")
}
@(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(t, write_err == nil, "should write .env file")
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(t, sync_err == .None, "sync should not error")
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(t, err == .None, "sync should not error")
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(t, read_err == nil, "file should exist after restore")
if read_err == nil {
testing.expect_value(t, string(data), "SECRET=value")
}
}
@(test)
test_db_sync_dir_missing :: proc(t: ^testing.T) {
db, ok := db_init()
testing.expect(t, ok, "failed to create test db")
defer db_close(&db)
f := make_test_env_file("/nonexistent/path/.env", "sha", "KEY=val")
db_insert(&db, f)
result, err := db_sync(&db, &f)
testing.expect_value(t, err, SyncError.DirMissing)
testing.expect_value(t, result, nil)
}
@(test)
test_db_sync_moved :: proc(t: ^testing.T) {
base := test_temp_dir(t, "envr-test-sync-moved-*")
search_root := fmt.tprintf("%s/search", base)
repo_dir := fmt.tprintf("%s/myproject", search_root)
git_dir := fmt.tprintf("%s/.git", repo_dir)
defer os.remove_all(base)
os.mkdir_all(git_dir)
config_content := "[remote \"origin\"]\n\turl = git@github.com:user/repo.git\n"
config_path := fmt.tprintf("%s/config", git_dir)
write_err := os.write_entire_file(config_path, transmute([]u8)config_content)
testing.expect(t, write_err == nil, "should write .git/config")
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(t, err == .None, "sync should not error")
if err != .None do return
testing.expect(t, .DirUpdated in result, "should have DirUpdated flag")
testing.expect(t, .Restored in result, "should have Restored flag")
expected_path := fmt.tprintf("%s/.env", repo_dir)
testing.expect_value(t, f.path, expected_path)
testing.expect_value(t, f.dir, repo_dir)
_, old_exists := db_fetch(&db, "/old/nonexistent/path/.env")
testing.expect(t, !old_exists, "old path should be deleted from db")
new_fetched, new_ok := db_fetch(&db, expected_path)
testing.expect(t, new_ok, "new path should exist in db")
if new_ok {
testing.expect_value(t, new_fetched.contents, "SECRET=value")
}
}

View File

@@ -4,11 +4,11 @@ Manage your .env files.
### Synopsis ### Synopsis
envr keeps your .env synced to a local, age encrypted database. envr keeps your .env synced to a local, 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 ~/data.age All your data is stored in ~/.envr/data.envr
Getting started is easy: Getting started is easy:

View File

@@ -79,6 +79,13 @@
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,10 +1,36 @@
package main package main
import "base:runtime"
import "core:fmt" import "core:fmt"
import "core:mem" 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 { when ODIN_DEBUG {
heap_track: mem.Tracking_Allocator heap_track: mem.Tracking_Allocator
mem.tracking_allocator_init(&heap_track, context.allocator) mem.tracking_allocator_init(&heap_track, context.allocator)
@@ -60,3 +86,21 @@ 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,6 +2,7 @@ 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,
@@ -36,16 +37,16 @@ multi_select :: proc(
return return
} }
selected = make([dynamic]bool, 0, len(options)) selected = make([dynamic]bool, len(options))
cursor: int = 0 cursor: int = 0
scroll_offset: int = 0 scroll_offset: int = 0
fmt.printf("\x1b[?25l") fmt.printf(ansi.CSI + ansi.DECTCEM_HIDE)
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("\x1b[?25h") fmt.printf(ansi.CSI + ansi.DECTCEM_SHOW)
return return
} }
defer disable_raw_mode(&raw) defer disable_raw_mode(&raw)
@@ -65,18 +66,18 @@ multi_select :: proc(
case .Space: case .Space:
selected[cursor] = !selected[cursor] selected[cursor] = !selected[cursor]
case .Enter: case .Enter:
fmt.printf("\x1b[%dA\x1b[J\x1b[?25h", visible + 1) fmt.printf(ansi.CSI + "%d" + ansi.CUU + ansi.CSI + ansi.ED + ansi.CSI + ansi.DECTCEM_SHOW, visible + 1)
result = .Confirm result = .Confirm
return return
case .Escape: case .Escape:
fmt.printf("\x1b[%dA\x1b[J\x1b[?25h", visible + 1) fmt.printf(ansi.CSI + "%d" + ansi.CUU + ansi.CSI + ansi.ED + ansi.CSI + ansi.DECTCEM_SHOW, 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("\x1b[%dA\x1b[0J", visible + 1) fmt.printf(ansi.CSI + "%d" + ansi.CUU + ansi.CSI + ansi.RESET + ansi.ED, visible + 1)
visible = render_options(prompt, options, selected[:], cursor, scroll_offset) visible = render_options(prompt, options, selected[:], cursor, scroll_offset)
} }
} }
@@ -88,7 +89,7 @@ render_options :: proc(
cursor: int, cursor: int,
scroll_offset: int, scroll_offset: int,
) -> int { ) -> int {
fmt.printf("\x1b[1;36m%s\x1b[0m (↑/↓ move, space select, enter confirm)\r\n", prompt) fmt.printf(ansi.CSI + ansi.BOLD + ";" + ansi.FG_CYAN + ansi.SGR + "%s" + ANSI_RESET + " (↑/↓ move, space select, enter confirm)\r\n", prompt)
end := scroll_offset + MAX_VISIBLE end := scroll_offset + MAX_VISIBLE
if end > len(options) { if end > len(options) {
@@ -101,9 +102,9 @@ render_options :: proc(
checkbox = "x" checkbox = "x"
} }
if i == cursor { if i == cursor {
fmt.printf("\x1b[1;32m> \x1b[0m[\x1b[32m%s\x1b[0m] %s\r\n", checkbox, options[i]) fmt.printf(ansi.CSI + ansi.BOLD + ";" + ansi.FG_GREEN + ansi.SGR + "> " + ANSI_RESET + "[" + ansi.CSI + ansi.FG_GREEN + ansi.SGR + "%s" + ANSI_RESET + "] %s\r\n", checkbox, options[i])
} else { } else {
fmt.printf(" [\x1b[2m%s\x1b[0m] %s\r\n", checkbox, options[i]) fmt.printf(" [" + ansi.CSI + ansi.FAINT + ansi.SGR + "%s" + ANSI_RESET + "] %s\r\n", checkbox, options[i])
} }
} }

View File

@@ -7,18 +7,19 @@ 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.ScanConfig.Matcher, pattern = cfg.scan_config.matcher,
excludes = cfg.ScanConfig.Exclude[:], excludes = cfg.scan_config.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)
@@ -29,3 +30,4 @@ find_unbacked :: proc(local_files: []string, db_files: []EnvFile) -> []string {
} }
return unbacked[:] return unbacked[:]
} }

View File

@@ -1,3 +1,4 @@
#+test
package main package main
import "core:fmt" import "core:fmt"
@@ -7,8 +8,7 @@ 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 := fmt.tprintf("/tmp/envr-scan-test-%d", os.get_pid()) base := test_temp_dir(t, "envr-scan-test-*")
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,23 +18,26 @@ 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)
if err != nil { testing.expectf(t, err == nil, "Failed to run git: %v", err)
return if err != nil do return
} state, wait_err := os.process_wait(p)
_, wait_err := os.process_wait(p) testing.expectf(t, wait_err == nil, "Failed to wait: %v", wait_err)
if wait_err != nil { if wait_err != nil do return
return testing.expect(t, state.success, "command should succeed")
}
gitignore_path := fmt.tprintf("%s/.gitignore", base) gitignore_path := fmt.tprintf("%s/.gitignore", base)
_ = os.write_entire_file(gitignore_path, ".env*\n") err = os.write_entire_file(gitignore_path, ".env*\n")
testing.expectf(t, err == nil, "Failed: %v", err)
_ = os.write_entire_file(fmt.tprintf("%s/.env", base), "SECRET=1") err = os.write_entire_file(fmt.tprintf("%s/.env", base), "SECRET=1")
_ = os.write_entire_file(fmt.tprintf("%s/.env.testing", base), "TEST=1") testing.expectf(t, err == nil, "Failed: %v", err)
_ = os.write_entire_file(fmt.tprintf("%s/config.yaml", base), "key: value") err = os.write_entire_file(fmt.tprintf("%s/.env.testing", base), "TEST=1")
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 {
ScanConfig = ScanConfig{Matcher = "\\.env"}, scan_config = ScanConfig{matcher = "\\.env"},
} }
results, ok := scan_path(base, cfg) results, ok := scan_path(base, cfg)
@@ -70,12 +73,11 @@ 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 := fmt.tprintf("/tmp/envr-scan-empty-%d", os.get_pid()) base := test_temp_dir(t, "envr-scan-empty-*")
os.mkdir_all(base)
defer os.remove_all(base) defer os.remove_all(base)
cfg := Config { cfg := Config {
ScanConfig = ScanConfig{Matcher = "\\.env"}, scan_config = ScanConfig{matcher = "\\.env"},
} }
results, ok := scan_path(base, cfg) results, ok := scan_path(base, cfg)
@@ -83,3 +85,4 @@ test_scan_path_empty_dir :: proc(t: ^testing.T) {
testing.expect(t, ok, "scan_path should succeed") testing.expect(t, ok, "scan_path should succeed")
testing.expect(t, len(results) == 0, fmt.tprintf("expected 0 results, got %d", len(results))) testing.expect(t, len(results) == 0, fmt.tprintf("expected 0 results, got %d", len(results)))
} }

View File

@@ -4,42 +4,51 @@ 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_RESIZEABLE :: 2 DESERIALIZE_FLAGS :: bit_set[DESERIALIZE_FLAG]
DESERIALIZE_FLAG :: enum u32 {
FREEONCLOSE = 1,
RESIZEABLE = 2,
}
foreign lib { foreign lib {
@(link_name="sqlite3_open") @(link_name = "sqlite3_open")
db_open :: proc(filename: cstring, ppDb: ^^rawptr) -> c.int --- open :: proc(filename: cstring, ppDb: ^Db) -> c.int ---
@(link_name="sqlite3_close") @(link_name = "sqlite3_close")
db_close :: proc(db: ^rawptr) -> c.int --- close :: proc(db: Db) -> c.int ---
@(link_name="sqlite3_errmsg") @(link_name = "sqlite3_errmsg")
db_errmsg :: proc(db: ^rawptr) -> cstring --- errmsg :: proc(db: Db) -> cstring ---
@(link_name="sqlite3_exec") @(link_name = "sqlite3_exec")
db_exec :: proc(db: ^rawptr, sql: cstring, callback: rawptr, callback_arg: rawptr, errmsg: ^cstring) -> c.int --- exec :: proc(db: Db, 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: ^rawptr, sql: cstring, nByte: c.int, ppStmt: ^^rawptr, pzTail: ^cstring) -> c.int --- prepare_v2 :: proc(db: Db, sql: cstring, nByte: c.int, ppStmt: ^Stmt, pzTail: ^cstring) -> c.int ---
@(link_name="sqlite3_step") @(link_name = "sqlite3_step")
step :: proc(stmt: ^rawptr) -> c.int --- step :: proc(stmt: Stmt) -> c.int ---
@(link_name="sqlite3_finalize") @(link_name = "sqlite3_finalize")
finalize :: proc(stmt: ^rawptr) -> c.int --- finalize :: proc(stmt: Stmt) -> c.int ---
@(link_name="sqlite3_column_text") @(link_name = "sqlite3_column_text")
column_text :: proc(stmt: ^rawptr, iCol: c.int) -> cstring --- column_text :: proc(stmt: Stmt, iCol: c.int) -> cstring ---
@(link_name="sqlite3_column_bytes") @(link_name = "sqlite3_column_bytes")
column_bytes :: proc(stmt: ^rawptr, iCol: c.int) -> c.int --- column_bytes :: proc(stmt: Stmt, iCol: c.int) -> c.int ---
@(link_name="sqlite3_bind_text") @(link_name = "sqlite3_bind_text")
bind_text :: proc(stmt: ^rawptr, idx: c.int, val: cstring, n: c.int, destructor: rawptr) -> c.int --- bind_text :: proc(stmt: Stmt, idx: c.int, val: cstring, n: c.int, destructor: rawptr) -> c.int ---
@(link_name="sqlite3_changes") @(link_name = "sqlite3_changes")
changes :: proc(db: ^rawptr) -> c.int --- changes :: proc(db: Db) -> c.int ---
@(link_name="sqlite3_serialize") @(link_name = "sqlite3_serialize")
serialize :: proc(db: ^rawptr, zSchema: cstring, piSize: ^i64, mFlags: u32) -> [^]u8 --- serialize :: proc(db: Db, zSchema: cstring, piSize: ^i64, mFlags: u32) -> [^]u8 ---
@(link_name="sqlite3_deserialize") @(link_name = "sqlite3_deserialize")
deserialize :: proc(db: ^rawptr, zSchema: cstring, pData: [^]u8, szDb: i64, szBuf: i64, mFlags: u32) -> c.int --- deserialize :: proc(db: Db, zSchema: cstring, pData: [^]u8, szDb: i64, szBuf: i64, mFlags: DESERIALIZE_FLAGS) -> 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) ---
} }

118
ssh.odin
View File

@@ -1,7 +1,10 @@
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"
@@ -43,9 +46,7 @@ parse_ssh_public_key :: proc(pub_path: string) -> (pub: [32]u8, ok: bool) {
return return
} }
for i in 0 ..< 32 { mem.copy_non_overlapping(&pub[0], raw_data(pk_data), 32)
pub[i] = pk_data[i]
}
ok = true ok = true
return return
@@ -85,15 +86,10 @@ 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 len(decoded) < len(magic) { if !strings.has_prefix(string(decoded), magic) {
return return
} }
for i in 0 ..< len(magic) {
if decoded[i] != u8(magic[i]) {
return
}
}
offset := len(magic) offset := len(magic)
@@ -115,8 +111,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 |
u32(decoded[offset + 2]) << 8 | u32(decoded[offset + 3]) num_keys := endian.get_u32(decoded[offset:offset + 4], .Big) or_return
offset += 4 offset += 4
if num_keys != 1 { if num_keys != 1 {
@@ -137,11 +133,16 @@ 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 |
u32(priv_blob[inner_offset + 2]) << 8 | u32(priv_blob[inner_offset + 3]) check1 := endian.get_u32(
transmute([]u8)(priv_blob)[inner_offset:inner_offset + 4],
.Big,
) or_return
inner_offset += 4 inner_offset += 4
check2 := u32(priv_blob[inner_offset]) << 24 | u32(priv_blob[inner_offset + 1]) << 16 | check2 := 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
if check1 != check2 { if check1 != check2 {
@@ -157,99 +158,44 @@ 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
} }
for i in 0 ..< 32 { mem.copy_non_overlapping(&kp.Public[0], raw_data(pub_wire), 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 {
kp.Private[i] = priv_wire[i] mem.copy_non_overlapping(&kp.Private[0], raw_data(priv_wire), 32)
}
ok = true ok = true
return return
} }
is_ed25519_key :: proc(priv_path: string) -> bool { is_ed25519_key :: proc(
pub_path, _ := strings.concatenate([]string{priv_path, ".pub"}, context.temp_allocator) priv_path: string,
_, ok := parse_ssh_public_key(pub_path) ) -> (
return ok ok: bool,
} err: runtime.Allocator_Error,
) #optional_allocator_error {
is_encrypted_key :: proc(priv_path: string) -> bool { pub_path := strings.concatenate([]string{priv_path, ".pub"}, context.temp_allocator) or_return
data, err := os.read_entire_file_from_path(priv_path, context.temp_allocator) _, ok = parse_ssh_public_key(pub_path)
if err != nil { return ok, nil
return true
}
if !strings.contains(string(data), "BEGIN OPENSSH PRIVATE KEY") {
return true
}
text := string(data)
lines := strings.split(text, "\n", context.temp_allocator)
b2: strings.Builder
strings.builder_init(&b2, context.temp_allocator)
defer strings.builder_destroy(&b2)
in_block := false
for line in lines {
trimmed := strings.trim_space(line)
if trimmed == "-----BEGIN OPENSSH PRIVATE KEY-----" {
in_block = true
continue
}
if trimmed == "-----END OPENSSH PRIVATE KEY-----" {
break
}
if in_block && len(trimmed) > 0 {
fmt.sbprintf(&b2, "%s", trimmed)
}
}
b64_str := strings.to_string(b2)
decoded, decode_err := base64.decode(b64_str, allocator = context.temp_allocator)
if decode_err != nil {
return true
}
magic := "openssh-key-v1\x00"
if len(decoded) < len(magic) {
return true
}
for i in 0 ..< len(magic) {
if decoded[i] != u8(magic[i]) {
return true
}
}
offset := len(magic)
ciphername, cipher_ok := read_wire_string(decoded, &offset)
if !cipher_ok {
return true
}
return ciphername != "none"
} }
read_wire_string :: proc(data: []u8, offset: ^int) -> (s: string, ok: bool) { read_wire_string :: proc(data: []u8, offset: ^int) -> (s: string, ok: bool) {
if offset^ + 4 > len(data) { if offset^ + 4 > len(data) {
return return
} }
length := u32(data[offset^]) << 24 | u32(data[offset^ + 1]) << 16 | length := endian.get_u32(data[offset^:offset^ + 4], .Big) or_return
u32(data[offset^ + 2]) << 8 | u32(data[offset^ + 3])
offset^ += 4 offset^ += 4
if offset^ + int(length) > len(data) { if offset^ + int(length) > len(data) {
return return
} }
s = string(data[offset^ : offset^ + int(length)]) s = string(data[offset^:offset^ + int(length)])
offset^ += int(length) offset^ += int(length)
ok = true ok = true
return return
} }

View File

@@ -1,9 +1,11 @@
#+test
package main package main
import "core:fmt" import "core:fmt"
import "core:os"
import "core:testing" import "core:testing"
TEST_KEY_DIR :: "fixtures/keys" TEST_KEY_DIR :: "fixtures" + os.Path_Separator_String + "keys"
@(test) @(test)
test_parse_ed25519_public_key :: proc(t: ^testing.T) { test_parse_ed25519_public_key :: proc(t: ^testing.T) {
@@ -70,39 +72,4 @@ test_read_wire_string :: proc(t: ^testing.T) {
testing.expect(t, s2 == "", "expected empty string") 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,89 +1,79 @@
package main package main
import "core:encoding/json"
import "core:fmt" import "core:fmt"
import "core:io" import "core:io"
import "core:strings" import "core:text/table"
import "core:unicode/utf8"
render_table :: proc(w: io.Writer, headers: []string, rows: [][]string) { decorations := table.Decorations {
col_widths := make([dynamic]int, 0, len(headers)) "┌",
for i in 0 ..< len(headers) { "┬",
append(&col_widths, strings.rune_count(headers[i])) "┐",
} "├",
for r in rows { "┼",
for i in 0 ..< len(r) { "┤",
rw := strings.rune_count(r[i]) "└",
if i < len(col_widths) && rw > col_widths[i] { "┴",
col_widths[i] = rw "┘",
} "│",
} "─",
} }
b: strings.Builder // TODO: Optimize ansi_aware_width
strings.builder_init(&b) ansi_aware_width :: proc(str: string) -> int {
defer strings.builder_destroy(&b) buf: [4096]byte
defer delete(col_widths) pos := 0
i := 0
hline :: proc(w: io.Writer, b: ^strings.Builder, left, mid, right: string, widths: [dynamic]int) { for i < len(str) {
strings.write_string(b, left) if i + 1 < len(str) && str[i] == 0x1b && str[i + 1] == '[' {
for i in 0 ..< len(widths) { i += 2
for _ in 0 ..< widths[i] + 2 { for i < len(str) {c := str[i]; i += 1; if c >= 0x40 && c <= 0x7E {break}}
strings.write_string(b, "\u2500")
}
if i < len(widths) - 1 {
strings.write_string(b, mid)
} else { } else {
strings.write_string(b, right) buf[pos] = str[i]; pos += 1; i += 1
} }
} }
fmt.wprintf(w, "%s\n", strings.to_string(b^), flush = false) _, _, width := utf8.grapheme_count(string(buf[:pos]))
strings.builder_reset(b) return width
}
hline(w, &b, "\u250c", "\u252c", "\u2510", col_widths)
cell :: proc(b: ^strings.Builder, s: string, width: int) {
extra := len(s) - strings.rune_count(s)
fmt.sbprintf(b, " %-*s \u2502", width + extra, s)
}
strings.write_string(&b, "\u2502")
for i in 0 ..< len(headers) {
cell(&b, headers[i], col_widths[i])
}
fmt.wprintf(w, "%s\n", strings.to_string(b), flush = false)
strings.builder_reset(&b)
hline(w, &b, "\u251c", "\u253c", "\u2524", col_widths)
for r in rows {
strings.write_string(&b, "\u2502")
for i in 0 ..< len(r) {
cell(&b, r[i], col_widths[i])
}
fmt.wprintf(w, "%s\n", strings.to_string(b), flush = false)
strings.builder_reset(&b)
}
hline(w, &b, "\u2514", "\u2534", "\u2518", col_widths)
} }
render_json_rows :: proc(w: io.Writer, headers: []string, rows: [][]string) { write_borderless_table :: proc(w: io.Writer, t: ^table.Table) {
entries := make([dynamic]map[string]string, 0, len(rows), context.temp_allocator) table.build(t, ansi_aware_width)
for row in rows { write_table_separator :: proc(w: io.Writer, tbl: ^table.Table) {
entry := make(map[string]string, len(headers), context.temp_allocator) io.write_byte(w, '\n')
for i in 0 ..< len(headers) {
entry[headers[i]] = row[i]
}
append(&entries, entry)
} }
data, err := json.marshal(entries[:], allocator = context.temp_allocator) if t.caption != "" {
if err != nil { table.write_text_align(
fmt.eprintf("Error marshaling JSON: %v\n", err) w,
return fmt.tprintf("%s%s%s", COLOR_HEADINGS, t.caption, ANSI_RESET),
.Left,
0, //t.lpad,
0, //t.rpad,
t.tblw + t.nr_cols - 1 - ansi_aware_width(t.caption) - t.lpad - t.rpad,
)
io.write_byte(w, '\n')
} }
fmt.wprintf(w, "%s", data, flush = false)
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)
}
}
write_table_separator(w, t)
}
table_reset :: proc(t: ^table.Table) {
clear(&t.cells)
clear(&t.colw)
t.caption = ""
t.tblw = 0
t.nr_cols = 0
t.nr_rows = 0
} }

View File

@@ -1,198 +1,33 @@
#+test
package main package main
import "core:encoding/json"
import "core:fmt"
import "core:strings"
import "core:testing" import "core:testing"
@(test) @(test)
test_render_json_rows_normal :: proc(t: ^testing.T) { test_ansi_aware_width_plain_ascii :: proc(t: ^testing.T) {
b: strings.Builder testing.expect_value(t, ansi_aware_width("hello"), 5)
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_render_json_rows_special_chars :: proc(t: ^testing.T) { test_ansi_aware_width_empty :: proc(t: ^testing.T) {
b: strings.Builder testing.expect_value(t, ansi_aware_width(""), 0)
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_render_json_rows_empty :: proc(t: ^testing.T) { test_ansi_aware_width_with_color_codes :: proc(t: ^testing.T) {
b: strings.Builder colored := COLOR_TABLE_HEADING + "Directory" + ANSI_RESET
strings.builder_init(&b) testing.expect_value(t, ansi_aware_width(colored), 9)
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_render_table_normal :: proc(t: ^testing.T) { test_ansi_aware_width_unicode :: proc(t: ^testing.T) {
b: strings.Builder testing.expect_value(t, ansi_aware_width("\u2713 Available"), 11)
strings.builder_init(&b) testing.expect_value(t, ansi_aware_width("\u2717 Missing"), 9)
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_render_table_empty :: proc(t: ^testing.T) { test_ansi_aware_width_multiple_escape_sequences :: proc(t: ^testing.T) {
b: strings.Builder colored := COLOR_TABLE_HEADING + "a" + ANSI_RESET + "b" + COLOR_TABLE_HEADING + "c" + ANSI_RESET
strings.builder_init(&b) testing.expect_value(t, ansi_aware_width(colored), 3)
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,
),
)
}

BIN
test_cond_import Executable file

Binary file not shown.