Architecture
Kosh internals — package layout, dependency injection, storage schema, and the data flow through a command.
This page covers how Kosh is put together internally: package responsibilities, how commands are wired, the storage schema, and where the boundaries are. It is aimed at contributors.
For the cryptography itself see Encryption Architecture; for ranking see Adaptive Search Algorithm.
Package map
Section titled “Package map”| Package | Responsibility |
|---|---|
cmd/ |
CLI surface — one file per subcommand, wired to Cobra |
cmd/profile/ |
The profile parent command and its list/create/delete subcommands |
internal/app |
app.Context — the dependency container passed to every command constructor |
internal/config |
~/.kosh/config.json load/save; active-profile resolution |
internal/core |
Business logic behind VaultService and ProfileService |
internal/crypto |
Thin wrappers around Go crypto primitives, plus secure file overwrite |
internal/storage |
SQLite persistence: Store interface and its implementation |
internal/model |
Plain data structs, encode/decode helpers, slog.LogValuer redaction |
internal/search |
Scoring and ranking |
internal/ui |
Terminal I/O — output, tables, prompts, interactive pickers, clipboard |
internal/logger |
log/slog setup, gated on KOSH_DEBUG |
internal/encoding |
Base64 helpers used at the model boundary |
internal/constants |
Sentinel errors, user-facing strings, tuning constants |
Dependency injection
Section titled “Dependency injection”Commands are constructors, not package-level globals:
func NewCmdList(ctx *app.Context) *cobra.Command { … }Each is registered in cmd/root.go:
rootCmd.AddCommand( NewCmdUse(appCtx), NewCmdInit(appCtx), NewCmdList(appCtx), NewCmdAdd(appCtx), NewCmdGenerate(appCtx), NewCmdGet(appCtx), NewCmdSearch(appCtx), NewCmdUpdate(appCtx), NewCmdDelete(appCtx), NewCmdCopy(appCtx), profile.NewCmdProfile(appCtx),)app.Context carries Config, Store, Vault and Profile. It is populated in
PersistentPreRun — load config → set the UI profile prefix → open the store → construct the
services — and the store is closed in PersistentPostRun.
This replaced package-level globals and init() side effects, which is what makes commands
testable.
Service interfaces
Section titled “Service interfaces”core.VaultService and core.ProfileService are interfaces, implemented by KoshVault and
KoshProfile. Commands depend on the interface, so tests can substitute fakes without touching
SQLite or the filesystem.
Profile name resolution
Section titled “Profile name resolution”Two rules govern every command that names a profile, and both are enforced in internal/core.
ResolveProfile returns the stored spelling
Section titled “ResolveProfile returns the stored spelling”ResolveProfile(name string) (model.Profile, bool, error)Lookup folds case, but a profile is always reported under the name it carries on disk. Callers
must use the returned name for anything that then touches the file — on a case-sensitive filesystem
work and Work are different paths, so opening a profile under the spelling the user typed rather
than the stored one would silently create an empty vault beside the real one.
Case is folded on every platform, not only the ones whose filesystem does. Two profiles differing only in case can coexist on Linux but collide the moment the directory is copied to Windows or macOS, so they are refused everywhere. Where a directory carried over from a case-sensitive machine does hold both, an exact-spelling match wins; otherwise the first match is used.
LoadProfiles(filter, exactMatch) backs it: it folds both sides of the comparison, returns
(nil, nil) when the profiles directory does not exist, and returns early on the first hit when
exactMatch is set.
SanitizeProfileName is create-only
Section titled “SanitizeProfileName is create-only”SanitizeProfileName(name string) (string, error)Applied by kosh profile create and nowhere else. In order: fold accents to ASCII
(encoding.RemoveAccent, via golang.org/x/text), trim, delete anything outside
[a-zA-Z0-9\s_-], collapse whitespace runs to _, collapse repeated _ and -, trim _- from
the ends, reject if empty, cap at 252 characters (255 less .db) and re-trim, then reject Windows
reserved device names. Case is preserved throughout.
Ordering in profile create is load-bearing
Section titled “Ordering in profile create is load-bearing”runCreate builds the vault before it touches any persistent state, and switches the active
profile only once the vault exists. That order is not stylistic — it is the only defence available:
ui.ReadSecretWithConfirmation installs a SIGINT handler that calls os.Exit(130)
(internal/ui/field.go). os.Exit runs no deferred functions, so no cleanup in the command layer
can ever run when the user presses Ctrl+C at a password prompt. Rollback code, defer, error
paths — none of it executes.
The earlier ordering switched the active profile before prompting, so an interrupt at the prompt left the config pointing at a profile whose vault had never been created. Because the store creates its file lazily, the next command would materialise it: a profile that existed, was active, and had no master password.
Persisting nothing before the prompt is the only reliable fix. Any future command that mutates state before prompting for input has the same bug.
Supporting changes: initVault(profile string) takes the profile name and builds its own
config.Config for the store, because it runs before the profile is active; rollbackCreate(ctx, name) only deletes the orphaned vault file, logging cleanup problems rather than returning them.
A narrow window remains — an interrupt after the password is confirmed but before the vault finishes
writing can still orphan a profile file. It is never the active profile and has no vault, so
kosh profile delete removes it without a password.
Destructive-path guard
Section titled “Destructive-path guard”DeleteProfile refuses any name that is not a local path (filepath.IsLocal) before it reaches the
file-scrubbing code. Traversal names like ../../other are already unreachable — ResolveProfile
only ever returns real directory entries — so this is defence in depth on the one destructive path
in the package.
The default-command trick
Section titled “The default-command trick”Kosh treats any unrecognised first argument as a search query. This is done by rewriting os.Args
before Cobra ever parses them:
if len(os.Args) == 1 { os.Args = append(os.Args, DEFAULT_COMMAND) // "search"} else { firstArg := os.Args[1] if !strings.HasPrefix(firstArg, "-") && !isKnownCommand(rootCmd, firstArg) { // ["kosh", "launch_codes"] becomes ["kosh", "search", "launch_codes"] os.Args = append(os.Args[:1], append([]string{DEFAULT_COMMAND}, os.Args[1:]...)...) }}isKnownCommand checks registered commands and their aliases, plus Cobra builtins that do not
appear in .Commands() (help, completion, __complete, __completeNoDesc).
This is also why a credential label may not be a subcommand name — the label would be swallowed by the dispatcher instead of reaching search.
Profile resolution and migration
Section titled “Profile resolution and migration”Execute() runs the legacy-layout migration before anything else:
if err := migrateToProfileFS(); err != nil { … }The migration is guarded so it is effectively idempotent:
- If
~/.kosh/profiles/default.dbexists → return immediately. - Else if
~/.kosh/kosh.dbdoes not exist → return immediately. - Else create
~/.kosh/profiles/(0700) andos.Renamethe legacy file toprofiles/default.db.
Because step 1 precedes everything, a kosh.db restored from a backup later can never clobber an
existing default profile.
The active profile is then read from ~/.kosh/config.json, which is created with
{"active_profile":"default"} if absent.
Storage
Section titled “Storage”Schema migrations
Section titled “Schema migrations”Migrations live in an ordered migrations []string slice and are applied transactionally on every
store init, tracked in a schema_migrations table. DDL was moved out of InitializeVault, which
now only inserts the vault row.
Tables
Section titled “Tables”The vault file is ~/.kosh/profiles/<name>.db. All crypto values are stored base64-encoded.
vault — one row per profile:
| Column | Contents |
|---|---|
public_key |
Curve25519 public key |
secret |
Curve25519 private key, encrypted |
nonce |
Nonce for the above |
salt |
Argon2id salt |
credentials — one row per credential:
| Column | Contents |
|---|---|
id |
Numeric handle used by update, delete, copy |
label, user |
Plaintext metadata; unique together |
ephemeral |
Per-credential ephemeral public key |
nonce |
Per-credential nonce |
secret |
Ciphertext |
access_count |
Retrieval counter, feeds search ranking |
accessed_at, updated_at, created_at |
Timestamps |
schema_migrations — applied migration bookkeeping.
The connection is opened with PRAGMA secure_delete=ON.
Error boundaries
Section titled “Error boundaries”The rule is: lower layers return errors without logging; Execute() logs once and prints once.
- Storage translates driver errors into sentinels —
constants.ErrCredentialNotFoundinstead of leakingsql.ErrNoRowsupward. - Storage and vault errors are wrapped with
%wand context as they propagate. - No layer below
cmd/calls intoui, and no layer belowcmd/logs an error it is returning. Execute()emits a singleslog.Debug("command failed", …)record and a singleui.Error(…)line, then exits1.
All user-facing strings live in internal/constants — errors.go, messages.go, prompts.go —
so wording is enforced project-wide rather than scattered across call sites.
Message conventions
Section titled “Message conventions”- Errors and messages are lowercase, unpunctuated and article-free; the UI supplies its own prefix.
- Errors read subject-first:
failed to save credential,profile does not exist. - Completed actions end in
successfully:credential saved successfully. - Inline prompts end with
": "; block prompts (confirmations, option lists) do not.
The internal/ui surface
Section titled “The internal/ui surface”| File | Contents |
|---|---|
output.go |
Colours, glyphs, the profile prefix, Caution, PauseOutput, WithProfile |
table.go |
Zero-dependency auto-sized table with an active-row pointer |
time.go |
RelativeTime — two significant units (02d 04h ago) |
field.go |
Prompts, password entry, typed confirmations, SIGINT handling |
search.go |
The interactive credential/profile pickers |
clipboard.go |
Single cross-platform clipboard path |
PauseOutput() swaps the output writers to io.Discard and silences the logger, returning a
restore function. It wraps raw-mode TUI sections so stray log lines cannot corrupt the display:
defer ui.PauseOutput()()WithProfile(p) follows the same restore-function shape, temporarily overriding the (profile)
prefix. kosh profile delete uses it so that output about the profile being deleted is labelled
with that profile rather than the active one:
defer ui.WithProfile(target)()The table renderer takes raw strings — embedding ANSI escapes in a cell would skew its column
width calculation, which is done with utf8.RuneCountInString.
Logging
Section titled “Logging”internal/logger is deliberately small: Setup() and Pause() around log/slog.
Setup() installs a text handler on stderr with AddSource: true. The level comes from
KOSH_DEBUG via strconv.ParseBool; anything unset, unparseable or falsy sets a sentinel level
above every real level, so nothing is emitted.
Pause() raises the level to that sentinel and returns a restore closure — the mechanism behind
ui.PauseOutput().
Redaction is structural: the model types implement slog.LogValuer, so sensitive fields render as
[REDACTED] in every record regardless of call site.
Dependencies
Section titled “Dependencies”Kosh is deliberately thin, and builds without CGO:
| Module | Purpose |
|---|---|
github.com/spf13/cobra |
CLI framework |
golang.org/x/crypto |
Argon2id, XChaCha20-Poly1305, Curve25519 |
golang.org/x/term |
Raw-mode terminal handling for password entry |
modernc.org/sqlite |
Pure-Go SQLite driver |
golang.design/x/clipboard |
Cross-platform clipboard |
golang.org/x/text |
Unicode normalization for accent folding in profile names |
The pure-Go SQLite driver is what keeps the released binaries statically linked and free of system dependencies.
Testing
Section titled “Testing”go test ./...Current coverage is strongest in internal/search, internal/core and internal/crypto, with
internal/ui/time_test.go covering relative-time formatting. The service interfaces in
internal/core exist specifically so command-level tests can run against fakes.
internal/core/profile_service_test.go covers the profile service end to end: the sanitization
table, case-insensitive resolution, exact-spelling preference, traversal names resolving to nothing,
filter behaviour, and the missing-directory case.
internal/search/search_test.go is the largest suite — see
test coverage for what it asserts.