Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
59 changes: 59 additions & 0 deletions internal/client/crypto/crypto.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
package crypto

import (
"crypto/aes"
"crypto/cipher"
"crypto/rand"
"fmt"

"golang.org/x/crypto/argon2"
)

func Encrypt(key, plaintext []byte) ([]byte, error) {
block, err := aes.NewCipher(key)
if err != nil {
return nil, fmt.Errorf("crypto: new cipher: %w", err)
}

gcm, err := cipher.NewGCM(block)
if err != nil {
return nil, fmt.Errorf("crypto: new gcm: %w", err)
}

nonce := make([]byte, gcm.NonceSize())
if _, err := rand.Read(nonce); err != nil {
return nil, fmt.Errorf("crypto: generate nonce: %w", err)
}

return gcm.Seal(nonce, nonce, plaintext, nil), nil
}

func Decrypt(key, ciphertext []byte) ([]byte, error) {
block, err := aes.NewCipher(key)
if err != nil {
return nil, fmt.Errorf("crypto: new cipher: %w", err)
}

gcm, err := cipher.NewGCM(block)
if err != nil {
return nil, fmt.Errorf("crypto: new gcm: %w", err)
}

if len(ciphertext) < gcm.NonceSize() {
return nil, fmt.Errorf("crypto: ciphertext shorter than nonce")
}

nonce, sealed := ciphertext[:gcm.NonceSize()], ciphertext[gcm.NonceSize():]

plaintext, err := gcm.Open(nil, nonce, sealed, nil)
if err != nil {
return nil, fmt.Errorf("crypto: decrypt: %w", err)
}

return plaintext, nil
}

func DeriveKey(passphrase, salt []byte) []byte {
key := argon2.IDKey(passphrase, salt, 1, 2*1024*1024, 4, 32)
return key
}
2 changes: 0 additions & 2 deletions internal/client/crypto/doc.go

This file was deleted.

2 changes: 0 additions & 2 deletions internal/client/vault/doc.go

This file was deleted.

83 changes: 83 additions & 0 deletions internal/client/vault/vault.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
package vault

import (
"crypto/sha256"
"encoding/hex"
"fmt"
"io"
"io/fs"
"os"
"path/filepath"
"time"
)

type FileInfo struct {
Path string
Size int64
ModTime time.Time
Hash string
}

func ScanVault(dir string) ([]FileInfo, error) {
var files []FileInfo

stat, err := os.Stat(dir)
if err != nil {
return nil, err
}
if !stat.IsDir() {
return nil, os.ErrNotExist
}

err = filepath.WalkDir(dir, func(path string, d fs.DirEntry, err error) error {
if err != nil {
return fmt.Errorf("vault: walk %s: %w", path, err)
}

if !d.IsDir() {
name, err := filepath.Rel(dir, path)
if err != nil {
return fmt.Errorf("vault: relative path for %s: %w", path, err)
}

info, err := d.Info()
if err != nil {
return fmt.Errorf("vault: stat %s: %w", path, err)
}

hash, err := hashFile(path)
if err != nil {
return fmt.Errorf("vault: hash %s: %w", path, err)
}

files = append(files, FileInfo{
Path: name,
Size: info.Size(),
ModTime: info.ModTime(),
Hash: hash,
})
}
return nil
})
if err != nil {
return nil, err
}

return files, nil
}

// SHA-256 of contents for change detection between scans.
func hashFile(path string) (string, error) {
f, err := os.Open(path)
if err != nil {
return "", err
}
defer f.Close()

h := sha256.New()
if _, err := io.Copy(h, f); err != nil {
return "", err
}

return hex.EncodeToString(h.Sum(nil)), nil
}
167 changes: 167 additions & 0 deletions internal/client/vault/vault_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,167 @@
package vault

import (
"crypto/sha256"
"encoding/hex"
"os"
"path/filepath"
"testing"
)

func writeFile(t *testing.T, dir, rel string, content []byte) {
t.Helper()
path := filepath.Join(dir, rel)
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
t.Fatalf("MkdirAll(%s): %v", filepath.Dir(path), err)
}
if err := os.WriteFile(path, content, 0o644); err != nil {
t.Fatalf("WriteFile(%s): %v", path, err)
}
}

func hashOf(content []byte) string {
sum := sha256.Sum256(content)
return hex.EncodeToString(sum[:])
}

func findByPath(t *testing.T, files []FileInfo, path string) FileInfo {
t.Helper()
for _, f := range files {
if f.Path == path {
return f
}
}
t.Fatalf("no FileInfo with Path %q in %+v", path, files)
return FileInfo{}
}

func TestScanVault(t *testing.T) {
root := t.TempDir()
writeFile(t, root, "note.md", []byte("hello"))
writeFile(t, root, "daily/2026-09-16.md", []byte("today's entry"))
writeFile(t, root, "attachments/photo.png", []byte("not actually a png"))

files, err := ScanVault(root)
if err != nil {
t.Fatalf("ScanVault() error: %v", err)
}
if len(files) != 3 {
t.Fatalf("got %d files, want 3: %+v", len(files), files)
}

note := findByPath(t, files, "note.md")
if note.Size != int64(len("hello")) {
t.Errorf("note.md Size = %d, want %d", note.Size, len("hello"))
}
if note.ModTime.IsZero() {
t.Error("note.md ModTime is zero")
}
if want := hashOf([]byte("hello")); note.Hash != want {
t.Errorf("note.md Hash = %q, want %q", note.Hash, want)
}

// Nested path is preserved, not collapsed to the basename.
daily := findByPath(t, files, filepath.Join("daily", "2026-09-16.md"))
if want := hashOf([]byte("today's entry")); daily.Hash != want {
t.Errorf("daily entry Hash = %q, want %q", daily.Hash, want)
}

// Non-markdown files are included too - the scan isn't note-only.
findByPath(t, files, filepath.Join("attachments", "photo.png"))
}

func TestScanVaultSameBasenameDifferentDirs(t *testing.T) {
root := t.TempDir()
writeFile(t, root, "folder1/note.md", []byte("first"))
writeFile(t, root, "folder2/note.md", []byte("second"))

files, err := ScanVault(root)
if err != nil {
t.Fatalf("ScanVault() error: %v", err)
}
if len(files) != 2 {
t.Fatalf("got %d files, want 2: %+v", len(files), files)
}

first := findByPath(t, files, filepath.Join("folder1", "note.md"))
second := findByPath(t, files, filepath.Join("folder2", "note.md"))

if first.Hash == second.Hash {
t.Error("distinct files under the same basename got the same Hash - content wasn't actually distinguished")
}
if first.Path == second.Path {
t.Error("distinct files under the same basename collapsed to the same Path")
}
}

func TestScanVaultEmptyDir(t *testing.T) {
root := t.TempDir()

files, err := ScanVault(root)
if err != nil {
t.Fatalf("ScanVault() error: %v", err)
}
if len(files) != 0 {
t.Errorf("got %d files, want 0: %+v", len(files), files)
}
}

func TestScanVaultNonexistentPath(t *testing.T) {
_, err := ScanVault(filepath.Join(t.TempDir(), "does-not-exist"))
if err == nil {
t.Fatal("expected an error for a nonexistent path, got nil")
}
}

func TestScanVaultPathIsAFile(t *testing.T) {
root := t.TempDir()
file := filepath.Join(root, "not-a-directory")
if err := os.WriteFile(file, []byte("x"), 0o644); err != nil {
t.Fatal(err)
}

_, err := ScanVault(file)
if err == nil {
t.Fatal("expected an error when the vault path is a file, got nil")
}
}

func TestScanVaultUnreadableSubdirSurfacesError(t *testing.T) {
if os.Getuid() == 0 {
t.Skip("running as root - permission bits don't block access")
}

root := t.TempDir()
writeFile(t, root, "visible.md", []byte("x"))

blocked := filepath.Join(root, "blocked")
if err := os.Mkdir(blocked, 0o755); err != nil {
t.Fatal(err)
}
writeFile(t, root, "blocked/secret.md", []byte("x"))
if err := os.Chmod(blocked, 0o000); err != nil {
t.Fatal(err)
}
t.Cleanup(func() { os.Chmod(blocked, 0o755) })

_, err := ScanVault(root)
if err == nil {
t.Fatal("expected ScanVault to surface an error for the unreadable subdirectory, got nil")
}
}

func TestScanVaultDirectoriesAreNotEntries(t *testing.T) {
root := t.TempDir()
writeFile(t, root, "notes/inbox.md", []byte("x"))

files, err := ScanVault(root)
if err != nil {
t.Fatalf("ScanVault() error: %v", err)
}

for _, f := range files {
if f.Path == "notes" {
t.Errorf("directory %q was included as a file entry", f.Path)
}
}
}
Loading