99 Commits

Author SHA1 Message Date
ac6c734722 feat: Added --color flag. 2026-06-26 14:15:20 -04:00
a4f4b10a7b refactor: Used RTTI for more sophisticated flag parsing. 2026-06-26 13:49:01 -04:00
581967a58d nix: Fixed the nix build. 2026-06-25 18:08:08 -04:00
6ec09309dd fix: -h short flag now works on subcommands. 2026-06-25 18:07:31 -04:00
c5020bd6a6 chore: Re-numbered todos. 2026-06-25 18:00:54 -04:00
d5981d7b88 feat: Added --format, -f flag.
Allows printing data in tabular or json format.
2026-06-25 17:53:44 -04:00
6fa68d10b1 style: Ignored allocation errors where possible. 2026-06-25 17:34:34 -04:00
13e9495642 refactor: Replaced fmt.printf calls with fmt.eprintf. 2026-06-25 17:21:14 -04:00
ad3de74e35 test: Rewrote expect checks to use expect_value where appropriate. 2026-06-25 17:13:50 -04:00
0b5bf4db73 perf: Improved the performance of table rendering. 2026-06-25 10:22:00 -04:00
96b3d6340a perf: remotes are now stored as a newline delimited list.
Previously they were saved as json.
2026-06-25 10:22:00 -04:00
5cc7973775 fix: Used os path separator rather than '/' where appropriate. 2026-06-24 17:55:54 -04:00
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
5059572951 fix: Fixed memory leaks in the db. 2026-06-18 10:35:21 -04:00
d2b84ac4c6 refactor(crypto): used a proper init procedure. 2026-06-18 08:18:09 -04:00
96bc218c46 style: Ordered procedures by usage, with main at the top. 2026-06-18 08:08:46 -04:00
3b32e365c9 chore: Updated TODOS.md 2026-06-18 07:45:38 -04:00
12574e123b feat: Removed runtime git dependency.
This also allowed us to drop the Features code.
2026-06-18 07:29:44 -04:00
bc464a3410 chore: Removed completed todo. 2026-06-17 18:03:54 -04:00
2ef733fe58 perf: Replaced fd with custom internals. 2026-06-17 17:56:31 -04:00
159ff91938 build: Incremented flake version number. 2026-06-16 11:56:10 -04:00
Spencer Brower
84550d4708 chore(main): release 0.3.0 2026-06-16 11:43:36 -04:00
fe2b256bd6 feat: All encryption/decryption now happens in-memory.
Release-as: v0.3.0
2026-06-16 11:38:20 -04:00
41decd9cdb ci: Updated release-please. 2026-06-16 11:36:05 -04:00
397f45d4d0 chore: Completed todos. 2026-06-16 11:36:05 -04:00
73a41830d1 docs: Removed completed TODOs. 2026-06-16 11:36:05 -04:00
e17d04c93d test: Table tests now view full output. 2026-06-16 11:36:05 -04:00
4600c81401 test: commands now accept stdout/stderr fields. 2026-06-16 11:36:05 -04:00
ec96dff055 chore: Cleaned up code. 2026-06-16 11:36:05 -04:00
4a26ee8145 feat: Config can be loaded from any path with --config-file (-c) flag. 2026-06-16 11:36:05 -04:00
e23ea960d7 test: Added missing tests. 2026-06-16 11:36:04 -04:00
3db86f0d2e refactor: Fixed cli command. 2026-06-16 11:36:04 -04:00
567cc8b1e2 tests: Added plan for improving testing. 2026-06-16 11:36:04 -04:00
fe3253f274 refactor: Fixed duplicate terminal checks. 2026-06-16 11:36:04 -04:00
f6ffeeee65 docs: Created table improvement plan. 2026-06-16 11:36:04 -04:00
23d5ff5e01 style: Removed unused code. 2026-06-16 11:36:04 -04:00
4599b25b1b refactor: Removed duplicate insert calls. 2026-06-16 11:36:04 -04:00
e32f0ea6d2 refactor: Fixed logic bug in db. 2026-06-16 11:36:03 -04:00
650c91d51b ci: Updated release-please. 2026-06-16 11:36:03 -04:00
ad3ce748bb docs: updated README.md 2026-06-16 11:36:03 -04:00
930c3d4c5d ci: Updated github action. 2026-06-16 11:36:03 -04:00
b1b0449b7b refactor: Removed go code. 2026-06-16 11:36:03 -04:00
0a74b0dbcc build: Converted Makefile and flake package. 2026-06-16 11:36:03 -04:00
d56f11250c refactor: removed is_tty. 2026-06-16 11:36:03 -04:00
23b8c2dc67 feat: Switched from age to libsodium.
This means, fewer dependencies, a smaller binary, and more secure data.

BREAKING CHANGE: The encryption format of databases has changed. Age
encryption is no longer supported, and no automatic migration path was
implemented.
2026-06-16 11:34:36 -04:00
2f4a7887ea docs: Updated TODOs. 2026-06-16 10:48:11 -04:00
5eee6cd6ea refactor(odin): Migrated nushell-completion command to go. 2026-06-16 10:48:10 -04:00
67f735a654 test: Added tests. 2026-06-16 10:48:10 -04:00
7d16dae4f4 refactor: Fixed the rest of the (tested) leaks. 2026-06-16 10:48:10 -04:00
365e9149b1 perf: Improved writer performance. 2026-06-16 10:48:10 -04:00
1068458f32 refactor: Fixed a number of memory leaks. 2026-06-16 10:48:10 -04:00
22a517340a refactor(odin): Added proper help text to all commands. 2026-06-16 10:48:10 -04:00
fcee4ca7b1 refactor: Got rid of go fallback code. 2026-06-16 10:48:10 -04:00
dff5235d65 refactor: Fixed memory leaks in find_binary. 2026-06-16 10:48:09 -04:00
5865315161 refactor(odin): Ported init command. 2026-06-16 10:48:09 -04:00
191ba305ef refactor(odin): Ported scan command. 2026-06-16 10:48:09 -04:00
d890c88b6d refactor(odin): port check command. 2026-06-16 10:48:09 -04:00
f8add2ad22 refactor(odin): Fixed AI mistakes. 2026-06-16 10:47:57 -04:00
2de7e20f5c refactor(odin): ported edit-config command. 2026-06-16 10:22:25 -04:00
8dd6b17cb9 refactor(odin): Ported restore command. 2026-06-16 10:22:08 -04:00
83b940337c refactor(odin): Ported remove command. 2026-06-16 10:21:46 -04:00
83a8caf691 refactor(odin): Added long text and --help flags. 2026-06-16 10:21:24 -04:00
1964698e35 refactor(odin): ported backup command. 2026-06-16 10:18:56 -04:00
de2186a2e5 refactor(odin): ported list command. 2026-06-16 10:18:21 -04:00
cb51a398ad refactor(odin): ported deps command, added utilities (features, tty, table). 2026-06-16 10:18:05 -04:00
e989b88303 refactor: scaffolded odin project with CLI parser, version command, Go fallback 2026-06-16 10:17:12 -04:00
71 changed files with 4720 additions and 2214 deletions

View File

@@ -2,8 +2,6 @@ on:
push: push:
branches: branches:
- main - main
- dev
- odin
permissions: permissions:
contents: write contents: write
@@ -25,3 +23,4 @@ jobs:
# this is a built-in strategy in release-please, see "Action Inputs" # this is a built-in strategy in release-please, see "Action Inputs"
# for more options # for more options
release-type: simple release-type: simple
target-branch: ${{ github.ref_name }}

5
.gitignore vendored
View File

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

View File

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

View File

@@ -10,7 +10,7 @@ LINUX_AMD64_BIN := $(BUILD_DIR)/$(APP_NAME)-$(VERSION)-linux-amd64
LINUX_ARM64_BIN := $(BUILD_DIR)/$(APP_NAME)-$(VERSION)-linux-arm64 LINUX_ARM64_BIN := $(BUILD_DIR)/$(APP_NAME)-$(VERSION)-linux-arm64
DARWIN_ARM64_BIN := $(BUILD_DIR)/$(APP_NAME)-$(VERSION)-darwin-arm64 DARWIN_ARM64_BIN := $(BUILD_DIR)/$(APP_NAME)-$(VERSION)-darwin-arm64
.PHONY: all clean cleanall build-linux build-darwin compress release 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

@@ -12,14 +12,13 @@ the tool [of your choosing](#backup-options).
## Features ## Features
- 🔐 **Encrypted Storage**: All `.env` files are encrypted using your ssh key and - **Encrypted Storage**: All `.env` files are encrypted using your ssh key and
[libsodium](https://github.com/jedisct1/libsodium) encryption. [libsodium](https://github.com/jedisct1/libsodium).
- 🔄 **Automatic Sync**: Update the database with one command, which can easily - **Automatic Sync**: Update the database with one command, which can easily
be run on a cron. be run on a cron.
- 🔍 **Smart Scanning**: Automatically discover and import `.env` files in your - **Smart Scanning**: Automatically discover and import `.env` files in your
home directory. home directory.
- **Interactive CLI**: User-friendly prompts for file selection and management. - **Rename Detection**: Automatically find and updates renamed/moved
- 🗂️ **Rename Detection**: Automatically finds and updates renamed/moved
repositories. repositories.
## TODOS ## TODOS
@@ -28,15 +27,11 @@ repositories.
- [x] Allow configuration of ssh key. - [x] Allow configuration of ssh key.
- [x] Allow multiple ssh keys. - [x] Allow multiple ssh keys.
## Prerequisites
- An SSH key pair (for encryption/decryption)
- The following binaries:
- [fd](https://github.com/sharkdp/fd)
- [git](https://git-scm.com)
## Installation ## Installation
You will need an SSH key pair for encryption and decryption. You can generate one
with `ssh-keygen -t ed25519`. It will be saved to `~/.ssh/id_ed25519`.
### With Odin ### With Odin
If you already have `odin` installed: If you already have `odin` installed:
@@ -96,7 +91,12 @@ The configuration file is created during initialization:
], ],
"scan": { "scan": {
"matcher": "\\.env", "matcher": "\\.env",
"exclude": "*.envrc", "exclude": [
"*\\.envrc",
"\\.local/",
"node_modules",
"vendor"
],
"include": "~" "include": "~"
} }
} }

63
TEST_PLAN.md Normal file
View File

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

108
TODOS.md
View File

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

View File

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

326
cli.odin
View File

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

View File

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

View File

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

View File

@@ -4,59 +4,46 @@ 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) {
feats := check_features() _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.allocator) cwd, cwd_err := os.get_working_directory(context.temp_allocator)
if cwd_err != nil { if cwd_err != nil {
fmt.printf("Error getting current directory: %v\n", cwd_err) 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.printf("Error getting absolute path: %v\n", abs_err) fmt.wprintf(cmd.err, "Error getting absolute path: %v\n", abs_err, flush = false)
return return
} }
abs_path = resolved
}
db, db_ok := db_open() db, db_ok := db_open(cmd.flags.config_file)
if !db_ok { if !db_ok {
return return
} }
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 {
if cant_scan(feats) { scanned, scan_ok := scan_path(check_path, db.cfg)
fmt.println(
"Error: please install fd to use the check command (https://github.com/sharkdp/fd)",
)
return
}
scanned, scan_ok := scan_path(abs_path, db.cfg)
if !scan_ok { if !scan_ok {
fmt.println("Error scanning directory for .env files") 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)
@@ -68,16 +55,25 @@ cmd_check :: proc(cmd: ^Command) {
if len(not_backed) == 0 { if len(not_backed) == 0 {
if len(files_in_path) == 0 { if len(files_in_path) == 0 {
fmt.println("No .env files found in the specified directory.") fmt.wprintln(cmd.out, "No .env files found in the specified directory.", flush = false)
} else { } else {
fmt.println("✓ All .env files in the directory are backed up.") fmt.wprintln(
cmd.out,
"✓ All .env files in the directory are backed up.",
flush = false,
)
} }
} else { } else {
fmt.printf("Found %d .env file(s) that are not backed up:\n", len(not_backed)) fmt.wprintf(
cmd.out,
"Found %d .env file(s) that are not backed up:\n",
len(not_backed),
flush = false,
)
for file in not_backed { for file in not_backed {
fmt.printf(" %s\n", file) fmt.wprintf(cmd.out, " %s\n", file, flush = false)
} }
fmt.println("\nRun 'envr sync' to back up these files.") fmt.wprintln(cmd.out, "\nRun 'envr sync' to back up these files.", flush = false)
} }
} }

View File

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

View File

@@ -1,23 +0,0 @@
package main
cmd_deps :: proc(cmd: ^Command) {
feats := check_features()
headers := []string{"Feature", "Status"}
rows: [dynamic][]string
if .Git in feats {
append(&rows, []string{"Git", "\u2713 Available"})
} else {
append(&rows, []string{"Git", "\u2717 Missing"})
}
if .Fd in feats {
append(&rows, []string{"fd", "\u2713 Available"})
} else {
append(&rows, []string{"fd", "\u2717 Missing"})
}
render_table(headers, rows[:])
}

View File

@@ -2,24 +2,23 @@ package main
import "core:fmt" import "core:fmt"
import "core:os" import "core:os"
import "core:path/filepath"
cmd_edit_config :: proc(cmd: ^Command) { cmd_edit_config :: proc(cmd: ^Command) {
editor := os.get_env("EDITOR", context.allocator) editor := os.get_env("EDITOR", context.allocator)
if len(editor) == 0 { if len(editor) == 0 {
fmt.println("Error: $EDITOR environment variable is not set") fmt.wprintln(cmd.err, "Error: $EDITOR environment variable is not set", flush = false)
return return
} }
config_path, join_err := filepath.join([]string{envr_dir(), "config.json"}) config_path := cmd.flags.config_file
if join_err != nil {
fmt.printf("Error building config path: %v\n", join_err)
return
}
_, stat_err := os.stat(config_path, context.allocator) if !os.exists(config_path) {
if stat_err != nil { fmt.wprintf(
fmt.printf("Config file does not exist at %s. Run 'envr init' first.\n", config_path) cmd.err,
"Config file does not exist at %s. Run 'envr init' first.\n",
config_path,
flush = false,
)
return return
} }
@@ -33,15 +32,17 @@ cmd_edit_config :: proc(cmd: ^Command) {
p, start_err := os.process_start(desc) p, start_err := os.process_start(desc)
if start_err != nil { if start_err != nil {
fmt.printf("Error running editor: %v\n", start_err) fmt.wprintf(cmd.err, "Error running editor: %v\n", start_err, flush = false)
return return
} }
state, wait_err := os.process_wait(p) state, wait_err := os.process_wait(p)
if wait_err != nil { if wait_err != nil {
fmt.printf("Error waiting for editor: %v\n", wait_err) 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,14 +1,22 @@
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 := cmd.flags.force
config_file := cmd.flags.config_file
_, cfg_exists := load_config() fmt.wprintln(cmd.out, cmd.flags.config_file, flush = false)
_, cfg_exists := load_config(config_file)
if cfg_exists && !force { if cfg_exists && !force {
fmt.println("You have already initialized envr.") fmt.wprintln(
fmt.println("Run again with the --force flag if you want to reinitialize.") cmd.out,
`You have already initialized envr.
Run again with the --force flag if you want to reinitialize.`,
flush = false,
)
return return
} }
@@ -18,14 +26,23 @@ cmd_init :: proc(cmd: ^Command) {
} }
if len(keys) == 0 { if len(keys) == 0 {
fmt.println("No ssh-ed25519 keys found in ~/.ssh") fmt.wprintln(
fmt.println("Generate one with: ssh-keygen -t ed25519") cmd.err,
`No ssh-ed25519 keys found in ~/.ssh
Generate one with: ssh-keygen -t ed25519`,
flush = false,
)
return return
} }
selected, result := multi_select("Select SSH private keys:", keys[:]) selected, result := multi_select("Select SSH private keys:", keys[:])
defer delete(selected)
if result == .Cancel { if result == .Cancel {
fmt.println("\x1b[2mCancelled.\x1b[0m") fmt.wprintln(
cmd.out,
ansi.CSI + ansi.FAINT + ansi.SGR + "Cancelled." + ANSI_RESET,
flush = false,
)
return return
} }
@@ -37,18 +54,20 @@ cmd_init :: proc(cmd: ^Command) {
} }
if len(selected_paths) == 0 { if len(selected_paths) == 0 {
fmt.println("No SSH keys selected - Config not created") fmt.wprintln(cmd.err, "No SSH keys selected - Config not created", flush = false)
return return
} }
cfg := new_config(selected_paths[:]) cfg := new_config(selected_paths[:], config_file)
if !save_config(cfg, force = force) { if !save_config(cfg, force = force) {
return return
} }
fmt.printf( fmt.wprintf(
cmd.out,
"Config initialized with %d SSH key(s). You are ready to use envr.\n", "Config initialized with %d SSH key(s). You are ready to use envr.\n",
len(selected_paths), len(selected_paths),
flush = false,
) )
} }

View File

@@ -5,15 +5,16 @@ import "core:fmt"
import "core:os" import "core:os"
import "core:path/filepath" import "core:path/filepath"
import "core:strings" import "core:strings"
import "core: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: Improve table rendering
cmd_list :: proc(cmd: ^Command) { cmd_list :: proc(cmd: ^Command) {
db, db_ok := db_open() db, db_ok := db_open(cmd.flags.config_file)
if !db_ok { if !db_ok {
return return
} }
@@ -23,41 +24,53 @@ cmd_list :: proc(cmd: ^Command) {
if !list_ok { if !list_ok {
return return
} }
defer delete(rows)
if terminal.is_terminal(os.stdout) { if cmd.flags.output == .Table {
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,
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(headers, table_rows[:]) table.write_decorated_table(cmd.out, &t, decorations, ansi_aware_width)
} else { } else {
entries: [dynamic]ListEntry // TODO: Should we instead print full entries here?
entries := make([dynamic]ListEntry, 0, len(rows), context.temp_allocator)
for row in rows { for row in rows {
filename := filepath.base(row.Path) filename := filepath.base(row.path)
append( append(
&entries, &entries,
ListEntry { ListEntry {
Directory = strings.concatenate({row.Dir, "/"}, context.temp_allocator), dir = strings.concatenate(
Path = filename, {row.dir, os.Path_Separator_String},
context.temp_allocator,
),
path = filename,
}, },
) )
} }
data, marshal_err := json.marshal(entries[:])
data, marshal_err := json.marshal(entries[:], allocator = context.temp_allocator)
if marshal_err != nil { if marshal_err != nil {
fmt.printf("Error marshaling JSON: %v\n", marshal_err) fmt.wprintf(cmd.err, "Error marshaling JSON: %v\n", marshal_err, flush = false)
return return
} }
fmt.println(string(data)) fmt.wprintln(cmd.out, string(data), flush = false)
} }
} }

View File

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

View File

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

View File

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

View File

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

View File

@@ -7,29 +7,23 @@ import "core:strings"
cmd_restore :: proc(cmd: ^Command) { cmd_restore :: proc(cmd: ^Command) {
if len(cmd.args) != 1 { if len(cmd.args) != 1 {
print_command_help("restore") print_command_help(cmd)
return return
} }
path := cmd.args[0] path := cmd.args[0]
if len(strings.trim_space(path)) == 0 { if len(strings.trim_space(path)) == 0 {
fmt.println("Error: No path provided") fmt.wprintln(cmd.err, "Error: No path provided", flush = false)
return return
} }
abs_path, abs_err := filepath.abs(path, context.temp_allocator)
abs_path: string
if filepath.is_abs(path) {
abs_path = path
} else {
resolved, abs_err := filepath.abs(path)
if abs_err != nil { if abs_err != nil {
fmt.printf("Error getting absolute path: %v\n", abs_err) fmt.wprintf(cmd.err, "Error getting absolute path: %v\n", abs_err, flush = false)
return return
} }
abs_path = resolved
}
db, db_ok := db_open() db, db_ok := db_open(cmd.flags.config_file)
if !db_ok { if !db_ok {
return return
} }
@@ -40,14 +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.printf("Error writing file: %v\n", write_err)
return return
} }
fmt.printf("Restored %s\n", file.Path) 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,34 +4,37 @@ 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) {
feats := check_features() db, db_ok := db_open(cmd.flags.config_file)
if cant_scan(feats) {
fmt.println(
"Error: please install fd to use the scan command (https://github.com/sharkdp/fd)",
)
return
}
db, db_ok := db_open()
if !db_ok { if !db_ok {
return return
} }
defer db_close(&db) defer db_close(&db)
search_dirs := search_paths(db.cfg) search_dirs := search_paths(db.cfg, context.temp_allocator)
if len(search_dirs) == 0 { if len(search_dirs) == 0 {
fmt.println("No search paths configured. Please run `envr init` or edit your config.") fmt.wprintln(
cmd.err,
"No search paths configured. Please run `envr init -f` or edit your config.",
flush = false,
)
return return
} }
// TODO: Figure out a sane default // TODO: Figure out a sane default
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.printf("Error scanning %s\n", dir) fmt.wprintf(cmd.err, "Error scanning %s\n", dir, flush = false)
continue continue
} }
for f in found { for f in found {
@@ -47,23 +50,33 @@ cmd_scan :: proc(cmd: ^Command) {
files := find_unbacked(all_files[:], db_files[:]) files := find_unbacked(all_files[:], db_files[:])
if len(files) == 0 { if len(files) == 0 {
fmt.println("No .env files found to add.") fmt.wprintln(cmd.out, "No .env files found to add.", flush = false)
return return
} }
if !terminal.is_terminal(os.stdout) { if !terminal.is_terminal(os.stdout) {
output, marshal_err := json.marshal(files[:]) output, marshal_err := json.marshal(files[:])
if marshal_err != nil { if marshal_err != nil {
fmt.printf("Error marshaling files to JSON: %v\n", marshal_err) fmt.wprintf(
cmd.err,
"Error marshaling files to JSON: %v\n",
marshal_err,
flush = false,
)
return return
} }
fmt.println(string(output)) fmt.wprintln(cmd.out, string(output), flush = false)
return return
} }
selected, result := multi_select("Select .env files to backup:", files[:]) selected, result := multi_select("Select .env files to backup:", files[:])
defer delete(selected)
if result == .Cancel { if result == .Cancel {
fmt.println("\x1b[2mCancelled.\x1b[0m") fmt.wprintln(
cmd.out,
ansi.CSI + ansi.FAINT + ansi.SGR + "Cancelled." + ANSI_RESET,
flush = false,
)
return return
} }
@@ -72,22 +85,40 @@ 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.printf("Error reading %s\n", files[i]) fmt.wprintf(cmd.err, "Error reading %s\n", files[i], flush = false)
continue continue
} }
if !db_insert(&db, env_file) { if !db_insert(&db, env_file) {
fmt.printf("Error adding %s\n", files[i]) fmt.wprintf(cmd.err, "Error adding %s\n", files[i], flush = false)
continue continue
} }
added_count += 1 added_count += 1
} }
if added_count > 0 { if added_count > 0 {
fmt.printf("\x1b[1;32mSuccessfully added %d file(s) to backup.\x1b[0m\n", added_count) fmt.wprintf(
cmd.out,
ansi.CSI +
ansi.BOLD +
";" +
ansi.FG_GREEN +
ansi.SGR +
"Successfully added %d file(s) to backup." +
ANSI_RESET +
"\n",
added_count,
flush = false,
)
} else { } else {
fmt.println("\x1b[2mNo files were added.\x1b[0m") fmt.wprintln(
cmd.out,
ansi.CSI + ansi.FAINT + ansi.SGR + "No files were added." + ANSI_RESET,
flush = false,
)
} }
} }

View File

@@ -2,18 +2,16 @@ package main
import "core:encoding/json" import "core:encoding/json"
import "core:fmt" import "core:fmt"
import "core:os" import "core:text/table"
import "core:strings"
import "core:terminal"
SyncEntry :: struct { SyncEntry :: struct {
Path: string `json:"path"`, path: string `json:"path"`,
Status: string `json:"status"`, status: string `json:"status"`,
} }
// TODO: Check for quiet failures. // TODO: Check for quiet failures.
cmd_sync :: proc(cmd: ^Command) { cmd_sync :: proc(cmd: ^Command) {
db, db_ok := db_open() db, db_ok := db_open(cmd.flags.config_file)
if !db_ok { if !db_ok {
return return
} }
@@ -23,71 +21,75 @@ cmd_sync :: proc(cmd: ^Command) {
if !list_ok { if !list_ok {
return return
} }
defer delete(files)
results: [dynamic]SyncEntry results := make([]SyncEntry, len(files), context.temp_allocator)
for &file in files { for &file, i in files {
old_path: string result, err := db_sync(&db, &file)
old_path, _ = strings.clone(file.Path)
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) if cmd.flags.output == .Table {
status_str, _ := strings.clone(status) t: table.Table
append(&results, SyncEntry{Path = path_str, Status = status_str}) table.init(&t, context.temp_allocator, context.temp_allocator)
} table.padding(&t, 1, 1)
if terminal.is_terminal(os.stdout) { table.aligned_header_of_values(
headers := []string{"File", "Status"} &t,
table_rows := make([dynamic][]string, 0, len(results)) .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(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.printf("Error marshaling JSON: %v\n", marshal_err) fmt.wprintf(cmd.err, "Error marshaling JSON: %v\n", marshal_err, flush = false)
return return
} }
fmt.println(string(data)) fmt.wprintln(cmd.out, string(data), flush = false)
} }
} }
sync_error_message :: proc(e: SyncError) -> string {
switch e {
case .None:
return ""
case .DirMissing:
return "directory missing"
case .MultipleDirs:
return "multiple directories found"
case .GitRootFailed:
return "failed to find git roots"
case .WriteFailed:
return "failed to write file"
case .ReadFailed:
return "failed to read file"
case .DbFailed:
return "failed to update database"
}
return "unknown error"
}

View File

@@ -5,10 +5,6 @@ import "core:fmt"
VERSION :: #load("version.txt", string) VERSION :: #load("version.txt", string)
cmd_version :: proc(cmd: ^Command) { cmd_version :: proc(cmd: ^Command) {
if has_flag(cmd, "long") || has_flag(cmd, "l") { fmt.wprintln(cmd.out, VERSION, flush = false)
fmt.printf("envr version %s\n", VERSION)
} else {
fmt.println(VERSION)
}
} }

86
colors.odin Normal file
View File

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

View File

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

View File

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

View File

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

View File

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

927
db.odin

File diff suppressed because it is too large Load Diff

View File

@@ -1,3 +1,4 @@
#+test
package main package main
import "core:fmt" import "core:fmt"
@@ -8,24 +9,38 @@ import "core:testing"
import "sqlite" import "sqlite"
FIXTURES :: "/home/spencer/github.com/envr-zig/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([]string{FIXTURES, "/insecure-test-key"}, context.allocator) priv, _ := strings.concatenate(
pub, _ := strings.concatenate([]string{FIXTURES, "/insecure-test-key.pub"}, context.allocator) []string{FIXTURES, "/keys/insecure-test-key"},
return SshKeyPair{Private = priv, Public = pub} context.temp_allocator,
)
pub, _ := strings.concatenate(
[]string{FIXTURES, "/keys/insecure-test-key.pub"},
context.temp_allocator,
)
return SshKeyPair{private = priv, public = pub}
} }
fixture_db_path :: proc() -> string { fixture_db_path :: proc() -> string {
p, _ := strings.concatenate([]string{FIXTURES, "/single-file.db"}, context.allocator) p, _ := strings.concatenate([]string{FIXTURES, "/single-file.db"}, context.temp_allocator)
return p return p
} }
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
} }
@@ -33,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()
@@ -44,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
@@ -52,12 +67,12 @@ test_encrypt_decrypt_sqlite_roundtrip :: proc(t: ^testing.T) {
defer delete(encrypted) defer delete(encrypted)
testing.expect(t, len(encrypted) >= HEADER_SIZE, "ciphertext should have header") testing.expect(t, len(encrypted) >= HEADER_SIZE, "ciphertext should have header")
testing.expect(t, encrypted[0] == u8('E'), "magic byte 0") testing.expect_value(t, encrypted[0], u8('E'))
testing.expect(t, encrypted[1] == u8('N'), "magic byte 1") testing.expect_value(t, encrypted[1], u8('N'))
testing.expect(t, encrypted[2] == u8('V'), "magic byte 2") testing.expect_value(t, encrypted[2], u8('V'))
testing.expect(t, encrypted[3] == u8('R'), "magic byte 3") testing.expect_value(t, encrypted[3], u8('R'))
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
@@ -86,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()
@@ -97,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)
@@ -119,21 +135,21 @@ test_encrypt_write_read_decrypt :: proc(t: ^testing.T) {
} }
defer delete(read_back) defer delete(read_back)
plaintext, dec_ok := decrypt(read_back, cfg.Keys[:]) plaintext, dec_ok := decrypt(read_back, cfg.keys[:])
testing.expect(t, dec_ok, "decryption after write/read should succeed") testing.expect(t, dec_ok, "decryption after write/read should succeed")
if !dec_ok { if !dec_ok {
return return
} }
defer delete(plaintext) defer delete(plaintext)
testing.expect(t, len(plaintext) == len(sqlite_data), "size mismatch after file round-trip") testing.expect_value(t, len(plaintext), len(sqlite_data))
} }
@(test) @(test)
test_decrypt_then_attach_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()
@@ -144,56 +160,54 @@ test_decrypt_then_attach_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)
tmp_db_path := fmt.tprintf("/tmp/envr-test-attach-%d.db", os.get_pid()) mem_db: sqlite.Db
write_err := os.write_entire_file(tmp_db_path, plaintext) rc := sqlite.open(":memory:", &mem_db)
testing.expectf(t, write_err == nil, "failed to write temp db: %v", write_err)
if write_err != nil {
return
}
defer os.remove(tmp_db_path)
mem_db: ^rawptr
rc := sqlite.db_open(":memory:", &mem_db)
testing.expectf(t, rc == sqlite.OK, "failed to open in-memory db") testing.expectf(t, rc == sqlite.OK, "failed to open in-memory db")
if rc != sqlite.OK { if rc != sqlite.OK {
return return
} }
defer sqlite.db_close(mem_db) defer sqlite.close(mem_db)
create_sql := "CREATE TABLE IF NOT EXISTS envr_env_files (path TEXT PRIMARY KEY NOT NULL, remotes TEXT, sha256 TEXT NOT NULL, contents TEXT NOT NULL)" n := i64(len(plaintext))
rc = sqlite.db_exec(mem_db, string_to_cstring(create_sql), nil, nil, nil) buf := sqlite.malloc64(n)
testing.expect(t, rc == sqlite.OK, "failed to create table") testing.expect(t, buf != nil, "malloc64 should succeed")
if buf == nil do return
copy(buf[:len(plaintext)], plaintext)
attach_ok := db_attach_and_copy(mem_db, tmp_db_path) rc = sqlite.deserialize(mem_db, "main", buf, n, n, {.FREEONCLOSE, .RESIZEABLE})
testing.expect(t, attach_ok, "failed to attach and copy") testing.expect_value(t, rc, sqlite.OK)
if rc != sqlite.OK {
sqlite.free(buf)
return
}
sql := "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, string_to_cstring(sql), -1, &stmt, nil) rc = sqlite.prepare_v2(mem_db, sql, -1, &stmt, nil)
testing.expect(t, rc == sqlite.OK, "prepare failed") testing.expect_value(t, rc, sqlite.OK)
if rc != sqlite.OK { if rc != sqlite.OK {
return return
} }
defer sqlite.finalize(stmt) defer sqlite.finalize(stmt)
rc = sqlite.step(stmt) rc = sqlite.step(stmt)
testing.expect(t, rc == sqlite.ROW, "expected at least one row") testing.expect_value(t, rc, sqlite.ROW)
if rc == sqlite.ROW { if rc == sqlite.ROW {
path := cstring_to_string(sqlite.column_text(stmt, 0)) path := string(sqlite.column_text(stmt, 0))
testing.expect(t, len(path) > 0, "path should not be empty") testing.expect(t, len(path) > 0, "path should not be empty")
} }
} }
@@ -201,9 +215,7 @@ test_decrypt_then_attach_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 { defer delete(cfg.keys)
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)
@@ -213,17 +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)
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 {
@@ -237,36 +254,28 @@ test_full_db_cycle :: proc(t: ^testing.T) {
} }
defer delete(read_back) defer delete(read_back)
plaintext, dec_ok := decrypt(read_back, cfg.Keys[:]) plaintext, dec_ok := decrypt(read_back, cfg.keys[:])
testing.expect(t, dec_ok, "decryption should succeed") testing.expect(t, dec_ok, "decryption should succeed")
if !dec_ok { if !dec_ok {
return return
} }
defer delete(plaintext) defer delete(plaintext)
encrypted2, enc2_ok := encrypt(plaintext, cfg.Keys[:]) encrypted2, enc2_ok := encrypt(plaintext, cfg.keys[:])
testing.expect(t, enc2_ok, "re-encryption should succeed") testing.expect(t, enc2_ok, "re-encryption should succeed")
if !enc2_ok { if !enc2_ok {
return return
} }
defer delete(encrypted2) defer delete(encrypted2)
plaintext2, dec2_ok := decrypt(encrypted2, cfg.Keys[:]) plaintext2, dec2_ok := decrypt(encrypted2, cfg.keys[:])
testing.expect(t, dec2_ok, "second decryption should succeed") testing.expect(t, dec2_ok, "second decryption should succeed")
if !dec2_ok { if !dec2_ok {
return return
} }
defer delete(plaintext2) defer delete(plaintext2)
testing.expect( testing.expect_value(t, len(plaintext2), len(original_data))
t,
len(plaintext2) == len(original_data),
fmt.tprintf(
"double round-trip size mismatch: expected %d, got %d",
len(original_data),
len(plaintext2),
),
)
os.remove(data_path) os.remove(data_path)
os.remove(envr_dir_path) os.remove(envr_dir_path)
@@ -278,13 +287,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
@@ -299,29 +308,28 @@ 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_value(t, len(x25519_pairs), 1)
} }
@(test) @(test)
test_config_load_with_fixture_key :: proc(t: ^testing.T) { test_config_load_with_fixture_key :: proc(t: ^testing.T) {
cfg := fixture_config() cfg := fixture_config()
defer { defer {
delete(cfg.Keys) delete(cfg.keys)
} }
testing.expect(t, len(cfg.Keys) == 1, "should have 1 key") testing.expect_value(t, len(cfg.keys), 1)
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,45 +1,216 @@
#+test
package main package main
import "core:crypto/hash"
import "core:encoding/hex"
import "core:fmt"
import "core:os"
import "core:path/filepath"
import "core:strings"
import "core:testing" import "core:testing"
@(test) import "sqlite"
test_db_update_required_noop :: proc(t: ^testing.T) {
testing.expect(t, !db_update_required({}), "Noop should not require update") make_test_env_file :: proc(path, sha, contents: string, remotes: []string = {}) -> EnvFile {
f := EnvFile {
path = path,
dir = "",
sha256 = sha,
contents = contents,
remotes = make([dynamic]string, 0, len(remotes), context.temp_allocator),
}
for r in remotes {
append(&f.remotes, r)
}
return f
} }
@(test) @(test)
test_db_update_required_backed_up :: proc(t: ^testing.T) { test_db_insert_and_fetch :: proc(t: ^testing.T) {
testing.expect(t, db_update_required({.BackedUp}), "BackedUp should require update") db, ok := db_init()
testing.expect(t, ok, "failed to create test db")
if !ok do return
defer db_close(&db)
path := "/project/.env"
sha := "abc123"
contents := "SECRET=value"
f := make_test_env_file(path, sha, contents, []string{"git@github.com:user/repo.git"})
defer delete(f.remotes)
testing.expect(t, db_insert(&db, f), "insert should succeed")
fetched, fetch_ok := db_fetch(&db, "/project/.env")
// defer delete_envfile(&fetched)
testing.expect(t, fetch_ok, "fetch should succeed")
if !fetch_ok do return
testing.expect_value(t, fetched.path, path)
testing.expect_value(t, fetched.sha256, sha)
testing.expect_value(t, fetched.contents, contents)
testing.expect_value(t, len(fetched.remotes), 1)
testing.expect_value(t, fetched.remotes[0], "git@github.com:user/repo.git")
} }
@(test) @(test)
test_db_update_required_dir_updated :: proc(t: ^testing.T) { test_db_fetch_missing :: proc(t: ^testing.T) {
testing.expect(t, db_update_required({.DirUpdated}), "DirUpdated should require update") db, ok := db_init()
testing.expect(t, ok, "failed to create test db")
if !ok do return
defer db_close(&db)
_, fetch_ok := db_fetch(&db, "/nonexistent/.env")
testing.expect(t, !fetch_ok, "fetch missing should return false")
} }
@(test) @(test)
test_db_update_required_restored :: proc(t: ^testing.T) { test_db_insert_or_replace :: proc(t: ^testing.T) {
testing.expect(t, !db_update_required({.Restored}), "Restored alone should not require update") db, ok := db_init()
defer db_close(&db)
testing.expect(t, ok, "failed to create test db")
f1 := make_test_env_file("/project/.env", "sha1", "KEY=old")
defer delete(f1.remotes)
testing.expect(t, db_insert(&db, f1), "first insert should succeed")
f2 := make_test_env_file("/project/.env", "sha2", "KEY=new")
defer delete(f2.remotes)
testing.expect(t, db_insert(&db, f2), "second insert should succeed")
results, list_ok := db_list(&db)
testing.expect(t, list_ok, "list should succeed")
testing.expect_value(t, len(results), 1)
fetched, fetch_ok := db_fetch(&db, "/project/.env")
testing.expect(t, fetch_ok, "fetch should succeed")
if !fetch_ok do return
// defer delete_envfile(&fetched)
testing.expect_value(t, fetched.contents, "KEY=new")
testing.expect_value(t, fetched.sha256, "sha2")
} }
@(test) @(test)
test_db_update_required_error :: proc(t: ^testing.T) { test_db_delete_existing :: proc(t: ^testing.T) {
testing.expect(t, !db_update_required({.Error}), "Error alone should not require update") db, ok := db_init()
testing.expect(t, ok, "failed to create test db")
if !ok do return
defer db_close(&db)
f := make_test_env_file("/project/.env", "sha", "KEY=val")
defer delete(f.remotes)
db_insert(&db, f)
testing.expect(t, db_delete(&db, "/project/.env"), "delete should return true")
_, fetch_ok := db_fetch(&db, "/project/.env")
testing.expect(t, !fetch_ok, "row should be gone after delete")
} }
@(test) @(test)
test_db_update_required_combined :: proc(t: ^testing.T) { test_db_delete_missing :: proc(t: ^testing.T) {
combined := SyncFlag{.DirUpdated, .Restored} db, ok := db_init()
testing.expect(t, db_update_required(combined), "DirUpdated|Restored should require update") testing.expect(t, ok, "failed to create test db")
if !ok do return
defer db_close(&db)
testing.expect(t, !db_delete(&db, "/nonexistent/.env"), "delete missing should return false")
}
@(test)
test_db_list_multiple :: proc(t: ^testing.T) {
db, ok := db_init()
testing.expect(t, ok, "failed to create test db")
defer db_close(&db)
f1 := make_test_env_file("/proj1/.env", "sha1", "A=1", []string{"git@github.com:a/repo.git"})
defer delete(f1.remotes)
f2 := make_test_env_file("/proj2/.env", "sha2", "B=2", []string{"git@github.com:b/repo.git"})
defer delete(f2.remotes)
f3 := make_test_env_file("/proj3/.env", "sha3", "C=3")
db_insert(&db, f1)
db_insert(&db, f2)
db_insert(&db, f3)
results, list_ok := db_list(&db)
testing.expect(t, list_ok, "list should succeed")
testing.expect_value(t, len(results), 3)
}
@(test)
test_db_list_empty :: proc(t: ^testing.T) {
db, ok := db_init()
testing.expect(t, ok, "failed to create test db")
defer db_close(&db)
results, list_ok := db_list(&db)
testing.expect(t, list_ok, "list should succeed on empty db")
testing.expect_value(t, len(results), 0)
}
@(test)
test_db_insert_sets_changed :: proc(t: ^testing.T) {
db, ok := db_init()
testing.expect(t, ok, "failed to create test db")
if !ok do return
defer db_close(&db)
testing.expect(t, !db.changed, "changed should start false")
f := make_test_env_file("/project/.env", "sha", "KEY=val")
defer delete(f.remotes)
db_insert(&db, f)
testing.expect(t, db.changed, "changed should be true after insert")
}
@(test)
test_db_delete_sets_changed :: proc(t: ^testing.T) {
db, ok := db_init()
testing.expect(t, ok, "failed to create test db")
if !ok do return
defer db_close(&db)
f := make_test_env_file("/project/.env", "sha", "KEY=val")
defer delete(f.remotes)
db_insert(&db, f)
db.changed = false
db_delete(&db, "/project/.env")
testing.expect(t, db.changed, "changed should be true after delete")
}
@(test)
test_db_serialize :: proc(t: ^testing.T) {
db, ok := db_init()
testing.expect(t, ok, "failed to create test db")
if !ok do return
defer db_close(&db)
f := make_test_env_file("/project/.env", "sha", "KEY=val")
defer delete(f.remotes)
db_insert(&db, f)
sz: i64
data := sqlite.serialize(db.conn, "main", &sz, {})
testing.expect(t, data != nil, "serialize should return non-nil")
if data == nil do return
defer sqlite.free(data)
testing.expect(t, sz > 0, "serialized size should be > 0")
} }
@(test) @(test)
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")
@@ -48,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")
@@ -59,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"}
@@ -69,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")
@@ -80,10 +251,311 @@ test_shares_remote_empty_check_remotes :: proc(t: ^testing.T) {
@(test) @(test)
test_shares_remote_both_empty :: proc(t: ^testing.T) { test_shares_remote_both_empty :: proc(t: ^testing.T) {
f := EnvFile { f := EnvFile {
Remotes = make([dynamic]string, 0), remotes = make([dynamic]string, 0),
} }
remotes: []string remotes: []string
testing.expect(t, !shares_remote(&f, remotes), "both empty should not share") testing.expect(t, !shares_remote(&f, remotes), "both empty should not share")
} }
delete_remotes :: proc(remotes: [dynamic]string) {
for &r in remotes {
delete(r)
}
delete(remotes)
}
@(test)
test_get_git_remotes_single :: proc(t: ^testing.T) {
base := test_temp_dir(t, "envr-test-remotes-*")
defer os.remove_all(base)
git_dir := fmt.tprintf("%s/.git", base)
os.mkdir_all(git_dir)
config_content := "[core]\n\trepositoryformatversion = 0\n[remote \"origin\"]\n\turl = git@github.com:user/repo.git\n\tfetch = +refs/heads/*:refs/remotes/origin/*\n"
config_path := fmt.tprintf("%s/config", git_dir)
err := os.write_entire_file(config_path, transmute([]u8)config_content)
testing.expect_value(t, err, nil)
remotes := get_git_remotes(base, context.temp_allocator)
testing.expect_value(t, len(remotes), 1)
if len(remotes) != 1 do return
testing.expect_value(t, remotes[0], "git@github.com:user/repo.git")
}
@(test)
test_get_git_remotes_multiple :: proc(t: ^testing.T) {
base := test_temp_dir(t, "envr-test-remotes-multi-*")
defer os.remove_all(base)
git_dir := fmt.tprintf("%s/.git", base)
os.mkdir_all(git_dir)
config_content := "[remote \"origin\"]\n\turl = git@github.com:user/repo.git\n[remote \"upstream\"]\n\turl = https://gitlab.com/upstream/repo.git\n"
config_path := fmt.tprintf("%s/config", git_dir)
err := os.write_entire_file(config_path, transmute([]u8)config_content)
testing.expect_value(t, err, nil)
remotes := get_git_remotes(base, context.temp_allocator)
testing.expect_value(t, len(remotes), 2)
}
@(test)
test_get_git_remotes_no_config :: proc(t: ^testing.T) {
base := test_temp_dir(t, "envr-test-remotes-none-*")
defer os.remove_all(base)
remotes := get_git_remotes(base, context.temp_allocator)
testing.expect_value(t, len(remotes), 0)
}
@(test)
test_get_git_remotes_no_remotes :: proc(t: ^testing.T) {
base := test_temp_dir(t, "envr-test-remotes-empty-*")
defer os.remove_all(base)
git_dir := fmt.tprintf("%s/.git", base)
os.mkdir_all(git_dir)
config_content := "[core]\n\trepositoryformatversion = 0\n\tbare = false\n"
config_path := fmt.tprintf("%s/config", git_dir)
err := os.write_entire_file(config_path, transmute([]u8)config_content)
testing.expect_value(t, err, nil)
remotes := get_git_remotes(base, context.temp_allocator)
testing.expect_value(t, len(remotes), 0)
}
@(test)
test_new_env_file :: proc(t: ^testing.T) {
base := test_temp_dir(t, "envr-test-envfile-*")
defer os.remove_all(base)
env_path := fmt.tprintf("%s/.env", base)
err := os.write_entire_file(env_path, "SECRET=value\n")
testing.expect_value(t, err, nil)
file, ok := new_env_file(env_path)
testing.expect(t, ok, "new_env_file should succeed")
if !ok do return
defer delete(file.contents)
defer delete(file.remotes)
defer delete(file.sha256)
defer delete(file.path)
testing.expect(t, filepath.is_abs(file.path), "path should be absolute")
testing.expect(t, strings.has_suffix(file.path, "/.env"), "path should end with /.env")
testing.expect_value(t, file.contents, "SECRET=value\n")
testing.expect_value(t, len(file.sha256), 64)
}
@(test)
test_new_env_file_missing :: proc(t: ^testing.T) {
_, ok := new_env_file("/tmp/envr-nonexistent-envfile/path/.env")
testing.expect(t, !ok, "missing file should return false")
}
@(test)
test_closing_db_has_no_leaks :: proc(t: ^testing.T) {
base := test_temp_dir(t, "envr-test-leak-*")
defer os.remove_all(base)
cfg_path, err := filepath.join([]string{base, "config.json"}, context.temp_allocator)
testing.expect_value(t, err, nil)
{
cfg := new_config([]string{"fixtures/keys/insecure-test-key"}, cfg_path)
testing.expect(t, save_config(cfg, force = true), "save should succeed")
delete_config(&cfg)
}
db, ok := db_open(cfg_path)
testing.expect(t, ok, "db should open")
db_close(&db)
}
@(test)
test_open_existing_db_has_no_leaks :: proc(t: ^testing.T) {
base := test_temp_dir(t, "envr-test-leak-existing-*")
defer os.remove_all(base)
cfg_path, err := filepath.join([]string{base, "config.json"}, context.temp_allocator)
testing.expect_value(t, err, nil)
{
cfg := new_config([]string{"fixtures/keys/insecure-test-key"}, cfg_path)
testing.expect(t, save_config(cfg, force = true), "save should succeed")
delete_config(&cfg)
}
// First open/close creates data.envr on disk
db, ok := db_open(cfg_path)
testing.expect(t, ok, "db should open")
if !ok do return
f := make_test_env_file(
"/project/.env",
"abc123",
"SECRET=value",
[]string{"git@github.com:user/repo.git"},
)
defer delete(f.remotes)
testing.expect(t, db_insert(&db, f), "insert should succeed")
db_close(&db)
// Second open exercises db_restore_from_encrypted
db2, ok2 := db_open(cfg_path)
testing.expect(t, ok2, "db should open existing")
if !ok2 do return
db_close(&db2)
}
@(test)
test_db_sync_noop :: proc(t: ^testing.T) {
base := test_temp_dir(t, "envr-test-sync-noop-*")
defer os.remove_all(base)
env_path := fmt.tprintf("%s/.env", base)
content := "KEY=value\n"
write_err := os.write_entire_file(env_path, transmute([]u8)content)
testing.expect_value(t, write_err, nil)
digest := hash.hash_bytes(
hash.Algorithm.SHA256,
transmute([]u8)content,
context.temp_allocator,
)
hex_bytes := hex.encode(digest, context.temp_allocator)
sha := string(hex_bytes)
db, ok := db_init()
testing.expect(t, ok, "failed to create test db")
defer db_close(&db)
f := make_test_env_file(env_path, sha, content)
f.dir = base
db_insert(&db, f)
result, sync_err := db_sync(&db, &f)
testing.expect_value(t, sync_err, SyncError.None)
testing.expect_value(t, result, nil)
}
@(test)
test_db_sync_backed_up :: proc(t: ^testing.T) {
base := test_temp_dir(t, "envr-test-sync-backup-*")
defer os.remove_all(base)
env_path := fmt.tprintf("%s/.env", base)
changed_content := "KEY=changed\n"
write_err := os.write_entire_file(env_path, transmute([]u8)changed_content)
testing.expect_value(t, write_err, nil)
db, ok := db_init()
testing.expect(t, ok, "failed to create test db")
defer db_close(&db)
f := make_test_env_file(env_path, "old_sha", "KEY=original")
f.dir = base
db_insert(&db, f)
result, sync_err := db_sync(&db, &f)
testing.expect_value(t, sync_err, SyncError.None)
testing.expect(t, .BackedUp in result, "should be backed up")
}
@(test)
test_db_sync_restored :: proc(t: ^testing.T) {
base := test_temp_dir(t, "envr-test-sync-restore-*")
defer os.remove_all(base)
env_path := fmt.tprintf("%s/.env", base)
db, ok := db_init()
testing.expect(t, ok, "failed to create test db")
defer db_close(&db)
f := make_test_env_file(env_path, "some_sha", "SECRET=value")
f.dir = base
defer delete(f.remotes)
db_insert(&db, f)
result, err := db_sync(&db, &f)
testing.expect_value(t, err, SyncError.None)
testing.expect(t, .Restored in result, "should be restored")
data, read_err := os.read_entire_file_from_path(env_path, context.temp_allocator)
testing.expect_value(t, read_err, nil)
if read_err == nil {
testing.expect_value(t, string(data), "SECRET=value")
}
}
@(test)
test_db_sync_dir_missing :: proc(t: ^testing.T) {
db, ok := db_init()
testing.expect(t, ok, "failed to create test db")
defer db_close(&db)
f := make_test_env_file("/nonexistent/path/.env", "sha", "KEY=val")
db_insert(&db, f)
result, err := db_sync(&db, &f)
testing.expect_value(t, err, SyncError.DirMissing)
testing.expect_value(t, result, nil)
}
@(test)
test_db_sync_moved :: proc(t: ^testing.T) {
base := test_temp_dir(t, "envr-test-sync-moved-*")
search_root := fmt.tprintf("%s/search", base)
repo_dir := fmt.tprintf("%s/myproject", search_root)
git_dir := fmt.tprintf("%s/.git", repo_dir)
defer os.remove_all(base)
os.mkdir_all(git_dir)
config_content := "[remote \"origin\"]\n\turl = git@github.com:user/repo.git\n"
config_path := fmt.tprintf("%s/config", git_dir)
write_err := os.write_entire_file(config_path, transmute([]u8)config_content)
testing.expect_value(t, write_err, nil)
db, ok := db_init()
testing.expect(t, ok, "failed to create test db")
defer db_close(&db)
db.cfg.scan_config.include = make([dynamic]string, 0, 1, context.temp_allocator)
append(&db.cfg.scan_config.include, search_root)
f := make_test_env_file(
"/old/nonexistent/path/.env",
"some_sha",
"SECRET=value",
[]string{"git@github.com:user/repo.git"},
)
testing.expect(t, db_insert(&db, f), "insert should succeed")
result, err := db_sync(&db, &f)
testing.expect_value(t, err, SyncError.None)
if err != .None do return
testing.expect(t, .DirUpdated in result, "should have DirUpdated flag")
testing.expect(t, .Restored in result, "should have Restored flag")
expected_path := fmt.tprintf("%s/.env", repo_dir)
testing.expect_value(t, f.path, expected_path)
testing.expect_value(t, f.dir, repo_dir)
_, old_exists := db_fetch(&db, "/old/nonexistent/path/.env")
testing.expect(t, !old_exists, "old path should be deleted from db")
new_fetched, new_ok := db_fetch(&db, expected_path)
testing.expect(t, new_ok, "new path should exist in db")
if new_ok {
testing.expect_value(t, new_fetched.contents, "SECRET=value")
}
}

View File

@@ -4,11 +4,11 @@ Manage your .env files.
### Synopsis ### Synopsis
envr keeps your .env synced to a local, 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:
@@ -45,7 +45,6 @@ at before, restore your backup with:
* [envr backup](envr_backup.md) - Import a .env file into envr * [envr backup](envr_backup.md) - Import a .env file into envr
* [envr check](envr_check.md) - check if files in the current directory are backed up * [envr check](envr_check.md) - check if files in the current directory are backed up
* [envr deps](envr_deps.md) - Check for missing binaries
* [envr edit-config](envr_edit-config.md) - Edit your config with your default editor * [envr edit-config](envr_edit-config.md) - Edit your config with your default editor
* [envr init](envr_init.md) - Set up envr * [envr init](envr_init.md) - Set up envr
* [envr list](envr_list.md) - View your tracked files * [envr list](envr_list.md) - View your tracked files

View File

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

View File

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

View File

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

299
findr/findr_test.odin Normal file
View File

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

88
findr/gitignore.odin Normal file
View File

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

118
findr/gitignore_test.odin Normal file
View File

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

203
findr/glob.odin Normal file
View File

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

111
findr/glob_test.odin Normal file
View File

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

128
findr/repos.odin Normal file
View File

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

148
findr/test_env.odin Normal file
View File

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

460
findr/walker.odin Normal file
View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

27
fixtures/keys/test_rsa Normal file
View File

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

View File

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

BIN
fixtures/single-file.db Normal file

Binary file not shown.

134
flags.odin Normal file
View File

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

View File

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

View File

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

View File

@@ -1,44 +0,0 @@
package main
import "core:fmt"
import "core:os"
main :: proc() {
cmd, ok := parse_args()
if !ok {
return
}
switch cmd.name {
case "init":
cmd_init(&cmd)
case "version":
cmd_version(&cmd)
case "deps":
cmd_deps(&cmd)
case "list":
cmd_list(&cmd)
case "backup", "add":
cmd_backup(&cmd)
case "remove":
cmd_remove(&cmd)
case "restore":
cmd_restore(&cmd)
case "edit-config":
cmd_edit_config(&cmd)
case "check":
cmd_check(&cmd)
case "scan":
cmd_scan(&cmd)
case "sync":
cmd_sync(&cmd)
case "nushell-completion":
cmd_nushell_completion(&cmd)
case:
fmt.printf("Unknown command: %s\n", cmd.name)
print_usage()
os.exit(1)
}
}

View File

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

109
quash
View File

@@ -1,109 +0,0 @@
@ ukspssxz spencer.brower@proton.me 2026-06-12 16:45:22 default@ 548fe7ec
(no description set)
suwmwvkl spencer.brower@proton.me 2026-06-12 16:40:25 odin a1e93345
│ ci: Updated github action.
tqpkpmus spencer.brower@proton.me 2026-06-12 16:35:39 eed36089
│ feat: Removed go code.
yzzzmznw spencer.brower@proton.me 2026-06-12 16:35:34 75b77845
│ build: Converted Makefile and flake package.
kvtmxpyn spencer.brower@proton.me 2026-06-12 15:54:44 4ec2b22b
│ refactor: removed `is_tty`.
pouwppuo spencer.brower@proton.me 2026-06-12 15:48:12 0276db76
│ refactor: Switched from age to libsodium.
txoxnuzl spencer.brower@proton.me 2026-06-12 15:36:10 a0e2c995
│ docs: Updated TODOs.
zvrkmqpk spencer.brower@proton.me 2026-06-12 15:01:50 d0dc93ab
│ feat(odin): Migrated nushell-completion command to go.
zpmvtmzx spencer.brower@proton.me 2026-06-12 14:50:42 91ada61c
│ feat: Added tests.
vsqmlvlq spencer.brower@proton.me 2026-06-12 14:17:56 9b395677
│ fix: Fixed the rest of the (tested) leaks.
rwzttsll spencer.brower@proton.me 2026-06-12 13:37:09 43dd8aca
│ perf: Improved writer performance.
rovqumvz spencer.brower@proton.me 2026-06-12 13:25:50 db1b863e
│ fix: fixing leaks.
quqsmwmx spencer.brower@proton.me 2026-06-12 10:45:43 e9660501
│ fix: Added proper help text to all commands.
uupootzn spencer.brower@proton.me 2026-06-12 10:28:41 7629dd2c
│ fix: Got rid of go fallback code.
svkzoqxq spencer.brower@proton.me 2026-06-12 10:22:21 7c7ddf46
│ fix: Fixed memory leaks in `find_binary`.
yzvwlzvq spencer.brower@proton.me 2026-06-12 10:22:21 a1e945a6
│ feat(odin): Ported init command.
yklwuqrm spencer.brower@proton.me 2026-06-12 09:12:55 0a332adf
│ feat(odin): Ported scan command.
unktymmr spencer.brower@proton.me 2026-06-12 08:27:14 4e1e3590
│ feat(odin): port check command to odin.
oyllntvp spencer.brower@proton.me 2026-06-12 08:02:08 82bec68b
│ fix: Fixing AI oopsies.
lowokuok spencer.brower@proton.me 2026-06-11 21:26:59 2cb6067a
│ feat(odin): ported edit-config command to odin.
vlssoopk spencer.brower@proton.me 2026-06-11 21:25:11 3668df57
│ feat(odin): ported restore command to odin.
tunwtypr spencer.brower@proton.me 2026-06-11 21:21:59 d2127e47
│ feat(odin): Ported remove command.
nrnpskps spencer.brower@proton.me 2026-06-11 21:17:52 cb7db967
│ feat(odin): Added long text and --help flags.
swwzkunx spencer.brower@proton.me 2026-06-11 21:14:11 c92155a1
│ feat(odin): ported backup command.
tsnurnzr spencer.brower@proton.me 2026-06-11 21:05:39 b1d24161
│ feat(odin): ported list command.
vwolkxsl spencer.brower@proton.me 2026-06-11 21:05:33 40f0b3c3
│ feat(odin): ported deps command, added utilities (features, tty, table).
rqrrlqlk spencer.brower@proton.me 2026-06-11 20:34:53 d84e43d0
│ odin: scaffold project with CLI parser, version command, Go fallback
znnskorn spencer.brower@proton.me 2026-06-11 20:08:27 28f96df4
│ feat: Started odin setup.
│ ○ rykmnnwl spencer.brower@proton.me 2026-06-11 20:00:08 zig 42c01a08
│ │ feat: init command.
│ ○ ztntvnnw spencer.brower@proton.me 2026-06-09 11:01:15 d3eb4e84
│ │ fix: Fixed issue with buffer size.
│ ○ pqzlpytk spencer.brower@proton.me 2026-06-09 09:50:38 6acd1f9d
│ │ refactor: Moved deps into `root.zig`.
│ ○ slkwsoqy spencer.brower@proton.me 2026-06-09 09:41:13 681931fb
│ │ feat: Added table viewer.
│ ○ qkmlntsm spencer.brower@proton.me 2026-05-27 19:30:19 acbda090
│ │ feat: list cmd.
│ ○ vxnsyxqp spencer.brower@proton.me 2026-05-27 18:27:21 fc8474d7
│ │ feat: Restore db from file.
│ ○ uoowvkxx spencer.brower@proton.me 2026-05-03 12:45:43 8f2c2419
│ │ feat(config): Added data path.
│ ○ qrkuztko spencer.brower@proton.me 2026-05-01 10:30:12 3e6c1752
│ │ feat: accept config in Db
│ ○ vrxoyzlo spencer.brower@proton.me 2026-04-30 22:37:31 fd0f8bba
│ │ feat(age): accept multiple recipients.
│ ○ rquvonut spencer.brower@proton.me 2026-04-30 21:03:38 65571393
│ │ feat: Implemented basic db operation.
│ ○ nwzoqvoq spencer.brower@proton.me 2026-04-29 16:35:38 e5286527
│ │ feat: Created own age wrapper.
│ ○ rltyxtqr spencer.brower@proton.me 2026-04-28 17:49:04 02ce5e46
│ │ feat: Added age-ffi.
│ ○ krzuylpu spencer.brower@proton.me 2026-04-26 17:29:37 a13264c8
│ │ feat: zig-sqlite.
│ ○ nqlotzkk spencer.brower@proton.me 2026-04-24 11:19:31 799d95a4
│ │ feat: added Config parsing.
│ ○ npvzptmw spencer.brower@proton.me 2026-04-23 16:53:47 217bb413
│ │ feat(comma): Added help method.
│ ○ rrlywnkm spencer.brower@proton.me 2026-04-21 19:42:02 a547409e
│ │ docs: Added AI Disclaimer to README.md.
│ ○ plqqwlws spencer.brower@proton.me 2026-04-21 19:34:09 53cf22bc
│ │ feat: Added help output for commands.
│ ○ znpvknpm spencer.brower@proton.me 2026-04-21 18:13:35 ae445459
│ │ feat(comma): Added enum value for unknown commands.
│ ○ zqpvlvms spencer.brower@proton.me 2026-04-21 18:02:58 bd2a5455
│ │ feat: Migrated `deps` command.
│ ○ wqslwyqo spencer.brower@proton.me 2026-04-20 17:08:26 8a503ced
│ │ refactor: Broke comma into a separate package.
│ ○ trqurnkq spencer.brower@proton.me 2026-04-20 16:14:43 33b0063c
│ │ feat: Added command structure.
│ │ ○ spllvvwm spencer.brower@proton.me 2026-04-20 10:15:48 envr-zig@ ac94b33e
│ ├─╯ (empty) (no description set)
│ ○ olwurpsw spencer.brower@proton.me 2026-04-18 16:28:30 43b03e0a
│ │ wip: feat: Migrated version command to zig.
│ ○ mnqunpro spencer.brower@proton.me 2026-04-17 16:41:45 ce135e9c
│ │ feat: Created zig wrapper.
│ ○ unkrrvon spencer.brower@proton.me 2026-04-17 15:49:00 6a611150
├─╯ feat: Added zig config.
◆ psmotwus 6729162+sbrow@users.noreply.github.com 2026-01-12 15:42:05 go main v0.2.1 c6d03088
│ chore(main): release 0.2.1
~

128
scan.odin
View File

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

View File

@@ -1,3 +1,4 @@
#+test
package main package main
import "core:fmt" import "core:fmt"
@@ -7,11 +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) {
feats := check_features() base := test_temp_dir(t, "envr-scan-test-*")
testing.expect(t, cant_scan(feats) == false)
base := fmt.tprintf("/tmp/envr-scan-test-%d", os.get_pid())
os.mkdir_all(base)
defer os.remove_all(base) defer os.remove_all(base)
git_init := os.Process_Desc { git_init := os.Process_Desc {
@@ -21,27 +18,35 @@ 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)
defer delete(results) defer {
for path in results {
delete(path)
}
delete(results)
}
testing.expect(t, ok, "scan_path should succeed") testing.expect(t, ok, "scan_path should succeed")
found_env := false found_env := false
@@ -68,20 +73,16 @@ test_scan_path_finds_gitignored_env_files :: proc(t: ^testing.T) {
@(test) @(test)
test_scan_path_empty_dir :: proc(t: ^testing.T) { test_scan_path_empty_dir :: proc(t: ^testing.T) {
feats := check_features() base := test_temp_dir(t, "envr-scan-empty-*")
testing.expect(t, cant_scan(feats) == false)
base := fmt.tprintf("/tmp/envr-scan-empty-%d", os.get_pid())
os.mkdir_all(base)
defer os.remove_all(base) defer os.remove_all(base)
cfg := Config { cfg := Config {
ScanConfig = ScanConfig{Matcher = "\\.env"}, scan_config = ScanConfig{matcher = "\\.env"},
} }
results, ok := scan_path(base, cfg) results, ok := scan_path(base, cfg)
defer delete(results) defer delete(results)
testing.expect(t, ok, "scan_path should succeed") testing.expect(t, ok, "scan_path should succeed")
testing.expect(t, len(results) == 0, fmt.tprintf("expected 0 results, got %d", len(results))) testing.expect_value(t, len(results), 0)
} }

View File

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

140
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"
@@ -12,24 +15,6 @@ Ed25519Keypair :: struct {
Private: [32]u8, Private: [32]u8,
} }
read_wire_string :: proc(data: []u8, offset: ^int) -> (s: string, ok: bool) {
if offset^ + 4 > len(data) {
return
}
length := u32(data[offset^]) << 24 | u32(data[offset^ + 1]) << 16 |
u32(data[offset^ + 2]) << 8 | u32(data[offset^ + 3])
offset^ += 4
if offset^ + int(length) > len(data) {
return
}
s = string(data[offset^ : offset^ + int(length)])
offset^ += int(length)
ok = true
return
}
parse_ssh_public_key :: proc(pub_path: string) -> (pub: [32]u8, ok: bool) { parse_ssh_public_key :: proc(pub_path: string) -> (pub: [32]u8, ok: bool) {
data, err := os.read_entire_file_from_path(pub_path, context.temp_allocator) data, err := os.read_entire_file_from_path(pub_path, context.temp_allocator)
if err != nil { if err != nil {
@@ -61,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
@@ -103,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)
@@ -133,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 {
@@ -155,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 {
@@ -175,81 +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 {
pub_path := strings.concatenate([]string{priv_path, ".pub"}, context.temp_allocator) or_return
_, ok = parse_ssh_public_key(pub_path)
return ok, nil
} }
is_encrypted_key :: proc(priv_path: string) -> bool { read_wire_string :: proc(data: []u8, offset: ^int) -> (s: string, ok: bool) {
data, err := os.read_entire_file_from_path(priv_path, context.temp_allocator) if offset^ + 4 > len(data) {
if err != nil { return
return true }
length := endian.get_u32(data[offset^:offset^ + 4], .Big) or_return
offset^ += 4
if offset^ + int(length) > len(data) {
return
} }
if !strings.contains(string(data), "BEGIN OPENSSH PRIVATE KEY") { s = string(data[offset^:offset^ + int(length)])
return true offset^ += int(length)
} ok = true
return
text := string(data)
lines := strings.split(text, "\n", context.temp_allocator)
b2: strings.Builder
strings.builder_init(&b2, context.temp_allocator)
defer strings.builder_destroy(&b2)
in_block := false
for line in lines {
trimmed := strings.trim_space(line)
if trimmed == "-----BEGIN OPENSSH PRIVATE KEY-----" {
in_block = true
continue
}
if trimmed == "-----END OPENSSH PRIVATE KEY-----" {
break
}
if in_block && len(trimmed) > 0 {
fmt.sbprintf(&b2, "%s", trimmed)
}
}
b64_str := strings.to_string(b2)
decoded, decode_err := base64.decode(b64_str, allocator = context.temp_allocator)
if decode_err != nil {
return true
}
magic := "openssh-key-v1\x00"
if len(decoded) < len(magic) {
return true
}
for i in 0 ..< len(magic) {
if decoded[i] != u8(magic[i]) {
return true
}
}
offset := len(magic)
ciphername, cipher_ok := read_wire_string(decoded, &offset)
if !cipher_ok {
return true
}
return ciphername != "none"
} }

View File

@@ -1,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 :: "/tmp/envr-test-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) {
@@ -44,15 +46,7 @@ test_private_key_pub_matches_public_key :: proc(t: ^testing.T) {
kp, priv_ok := parse_ssh_private_key(TEST_KEY_DIR + "/test_ed25519") kp, priv_ok := parse_ssh_private_key(TEST_KEY_DIR + "/test_ed25519")
testing.expect(t, priv_ok, "expected private key to parse") testing.expect(t, priv_ok, "expected private key to parse")
testing.expect( testing.expect_value(t, pub_from_pub, kp.Public)
t,
pub_from_pub == kp.Public,
fmt.tprintf(
"public key mismatch:\n from .pub: %v\n from priv: %v",
pub_from_pub,
kp.Public,
),
)
} }
@(test) @(test)
@@ -62,11 +56,11 @@ test_read_wire_string :: proc(t: ^testing.T) {
s, ok := read_wire_string(data, &offset) s, ok := read_wire_string(data, &offset)
testing.expect(t, ok, "expected read_wire_string to succeed") testing.expect(t, ok, "expected read_wire_string to succeed")
testing.expect(t, s == "hello", fmt.tprintf("expected 'hello', got %q", s)) testing.expect_value(t, s, "hello")
testing.expect(t, offset == 9, fmt.tprintf("expected offset 9, got %d", offset)) testing.expect_value(t, offset, 9)
s2, ok2 := read_wire_string(data, &offset) s2, ok2 := read_wire_string(data, &offset)
testing.expect(t, ok2, "expected second read to succeed") testing.expect(t, ok2, "expected second read to succeed")
testing.expect(t, s2 == "", "expected empty string") testing.expect_value(t, s2, "")
} }

View File

@@ -1,98 +1,75 @@
package main package main
import "core:encoding/json"
import "core:fmt" import "core:fmt"
import "core:io" import "core:io"
import "core:os" import "core:text/table"
import "core:strings"
import "core:terminal"
render_table :: proc(headers: []string, rows: [][]string) { decorations := table.Decorations {
if !terminal.is_terminal(os.stdout) { "┌",
w := io.to_writer(os.to_writer(os.stdout)) "┬",
render_json_rows(w, headers, rows) "┐",
io.write_string(w, "\n") "",
return "┼",
} "┤",
"└",
"┴",
"┘",
"│",
"─",
}
col_widths := make([dynamic]int, 0, len(headers)) ansi_aware_width :: proc(str: string) -> int #no_bounds_check {
for i in 0 ..< len(headers) { width := 0
append(&col_widths, strings.rune_count(headers[i])) for i := 0; i < len(str); {
} if i + 1 < len(str) && str[i] == 0x1b && str[i + 1] == '[' {
for r in rows { i += 2
for i in 0 ..< len(r) { for i < len(str) {c := str[i]; i += 1; if c >= 0x40 && c <= 0x7E {break}}
w := strings.rune_count(r[i])
if i < len(col_widths) && w > col_widths[i] {
col_widths[i] = w
}
}
}
b: strings.Builder
strings.builder_init(&b)
defer strings.builder_destroy(&b)
defer delete(col_widths)
hline :: proc(b: ^strings.Builder, left, mid, right: string, widths: [dynamic]int) {
strings.write_string(b, left)
for i in 0 ..< len(widths) {
for _ in 0 ..< widths[i] + 2 {
strings.write_string(b, "\u2500")
}
if i < len(widths) - 1 {
strings.write_string(b, mid)
} else { } else {
strings.write_string(b, right) width += 1
i += 1
} }
} }
fmt.println(strings.to_string(b^)) return width
strings.builder_reset(b)
}
hline(&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.println(strings.to_string(b))
strings.builder_reset(&b)
hline(&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.println(strings.to_string(b))
strings.builder_reset(&b)
}
hline(&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,104 +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_ansi_aware_width_multibyte :: proc(t: ^testing.T) {
testing.expect_value(t, ansi_aware_width("\u2713 Available"), 13)
testing.expect_value(t, ansi_aware_width("\u2717 Missing"), 11)
}
@(test)
test_ansi_aware_width_multiple_escape_sequences :: proc(t: ^testing.T) {
colored := COLOR_TABLE_HEADING + "a" + ANSI_RESET + "b" + COLOR_TABLE_HEADING + "c" + ANSI_RESET
testing.expect_value(t, ansi_aware_width(colored), 3)
}

BIN
test_cond_import Executable file

Binary file not shown.

View File

@@ -1 +1 @@
0.2.0 0.3.0