diff --git a/datanode/medium.go b/datanode/medium.go index 020b252..da53c8f 100644 --- a/datanode/medium.go +++ b/datanode/medium.go @@ -14,6 +14,7 @@ import ( "time" core "dappco.re/go/core" + coreio "dappco.re/go/core/io" borgdatanode "forge.lthn.ai/Snider/Borg/pkg/datanode" ) @@ -29,6 +30,8 @@ var ( } ) +var _ coreio.Medium = (*Medium)(nil) + // Example: medium := datanode.New() // Example: _ = medium.Write("jobs/run.log", "started") // Example: snapshot, _ := medium.Snapshot() diff --git a/docs/RFC-CORE-008-AGENT-EXPERIENCE.md b/docs/RFC-CORE-008-AGENT-EXPERIENCE.md index becda8e..1bf599c 100644 --- a/docs/RFC-CORE-008-AGENT-EXPERIENCE.md +++ b/docs/RFC-CORE-008-AGENT-EXPERIENCE.md @@ -41,7 +41,7 @@ AX does not replace UX or DX. End users still need good UX. Developers still nee Names are tokens that agents pattern-match across languages and contexts. Abbreviations introduce mapping overhead. -``` +```text Config not Cfg Service not Srv Embed not Emb diff --git a/docs/RFC.md b/docs/RFC.md index ca0031e..c6feb36 100644 --- a/docs/RFC.md +++ b/docs/RFC.md @@ -422,21 +422,6 @@ _ = m.Write("notes.txt", "hello") ok := m.IsFile("notes.txt") ``` -**Read(path string) (string, error)** -Example: -```go -m := io.NewMemoryMedium() -_ = m.Write("notes.txt", "hello") -value, _ := m.Read("notes.txt") -``` - -**Write(path, content string) error** -Example: -```go -m := io.NewMemoryMedium() -_ = m.Write("notes.txt", "hello") -``` - **Delete(path string) error** Example: ```go @@ -869,13 +854,13 @@ _ = n.Write("file.txt", "data") b, _ := n.ReadFile("file.txt") ``` -**CopyFile(sourcePath, destinationPath string, perm fs.FileMode) error** -Copies a file to the local filesystem. +**ExportFile(sourcePath, destinationPath string, perm fs.FileMode) error** +Exports a file from the in-memory tree to the local filesystem. Operates on coreio.Local directly — use CopyTo for Medium-agnostic transfers. Example: ```go n := node.New() _ = n.Write("file.txt", "data") -_ = n.CopyFile("file.txt", "/tmp/file.txt", 0644) +_ = n.ExportFile("file.txt", "/tmp/file.txt", 0644) ``` **CopyTo(target io.Medium, sourcePath, destPath string) error** diff --git a/docs/api-contract.md b/docs/api-contract.md index 05b1b4f..e0f05a5 100644 --- a/docs/api-contract.md +++ b/docs/api-contract.md @@ -126,7 +126,7 @@ Test coverage is `Yes` when same-package tests directly execute or reference the | `New` | `func New() *Node` | `dappco.re/go/core/io/node` | New creates a new, empty Node. | Yes | | `Node.AddData` | `func (*Node) AddData(name string, content []byte)` | `dappco.re/go/core/io/node` | AddData stages content in the in-memory filesystem. | Yes | | `Node.Append` | `func (*Node) Append(p string) (goio.WriteCloser, error)` | `dappco.re/go/core/io/node` | Append opens the named file for appending, creating it if needed. | No | -| `Node.CopyFile` | `func (*Node) CopyFile(src, dst string, perm fs.FileMode) error` | `dappco.re/go/core/io/node` | CopyFile copies a file from the in-memory tree to the local filesystem. | Yes | +| `Node.ExportFile` | `func (*Node) ExportFile(src, dst string, perm fs.FileMode) error` | `dappco.re/go/core/io/node` | ExportFile exports a file from the in-memory tree to the local filesystem. Use CopyTo for Medium-agnostic transfers. | Yes | | `Node.CopyTo` | `func (*Node) CopyTo(target coreio.Medium, sourcePath, destPath string) error` | `dappco.re/go/core/io/node` | CopyTo copies a file (or directory tree) from the node to any Medium. | No | | `Node.Create` | `func (*Node) Create(p string) (goio.WriteCloser, error)` | `dappco.re/go/core/io/node` | Create creates or truncates the named file, returning a WriteCloser. | No | | `Node.Delete` | `func (*Node) Delete(p string) error` | `dappco.re/go/core/io/node` | Delete removes a single file. | No | diff --git a/docs/architecture.md b/docs/architecture.md index 0d11aa6..d2fd0ea 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -94,7 +94,7 @@ Key capabilities beyond `Medium`: - **`ToTar()` / `FromTar()`** — serialise the entire tree to a tar archive and back. This enables snapshotting, transport, and archival. - **`Walk()` with `WalkOptions`** — extends `fs.WalkDir` with `MaxDepth`, `Filter`, and `SkipErrors` controls. -- **`CopyFile(src, dst, perm)`** — copies a file from the in-memory tree to the real filesystem. +- **`ExportFile(src, dst, perm)`** — exports a file from the in-memory tree to the local filesystem. Use `CopyTo` for Medium-agnostic transfers. - **`CopyTo(target Medium, src, dst)`** — copies a file or directory tree to any other `Medium`. - **`ReadFile(name)`** — returns a defensive copy of file content, preventing callers from mutating internal state. diff --git a/docs/security-attack-vector-mapping.md b/docs/security-attack-vector-mapping.md index 4db0e57..5808f9a 100644 --- a/docs/security-attack-vector-mapping.md +++ b/docs/security-attack-vector-mapping.md @@ -116,7 +116,7 @@ Notes: | `(*node.Node).Delete`, `DeleteAll`, `Rename` | `node/node.go:411`, `421`, `445` | Caller path(s) | Direct map mutation keyed by caller-supplied names | Only strips a leading `/` | Arbitrary delete/rename of any key, including `../`-style names; no directory-safe rename logic | | `(*node.Node).Stat`, `List`, `ReadDir`, `Exists`, `IsFile`, `IsDir` | `node/node.go:278`, `461`, `297`, `387`, `393`, `400` | Caller path/name | Directory inference from map keys and `fs` adapter methods | Only strips a leading `/` | Namespace enumeration and ambiguity around equivalent-looking path spellings | | `(*node.Node).WalkNode`, `Walk` | `node/node.go:128`, `145` | Caller root path, callback, filters | `fs.WalkDir` over the in-memory tree | No root normalization beyond whatever `Node` already does | Attackers who can plant names can force callback traversal over weird paths; `SkipErrors` can suppress unexpected failures | -| `(*node.Node).CopyFile` | `node/node.go:200` | Caller source key, destination host path, permissions | Reads node content and calls `os.WriteFile(dst, ...)` directly | Only checks that `src` exists and is not a directory | Arbitrary host filesystem write to a caller-chosen `dst` path | +| `(*node.Node).ExportFile` | `node/node.go:200` | Caller source key, destination host path, permissions | Reads node content and calls `coreio.Local.WriteMode(dst, ...)` directly | Only checks that `src` exists and is not a directory | Arbitrary host filesystem write to a caller-chosen `dst` path | | `(*node.Node).CopyTo` | `node/node.go:218` | Caller target medium, source path, destination path | Reads node entries and calls `target.Write(destPath or destPath/rel, content)` | Only checks that the source exists | Stored `../`-style node keys can propagate into destination paths, enabling traversal or overwrite depending on the target backend | | `(*node.Node).EnsureDir` | `node/node.go:380` | Caller path (ignored) | No-op | Input is ignored | Semantic mismatch: callers may assume a directory boundary was created when directories remain implicit | diff --git a/io.go b/io.go index 41a2f6d..f0c17fa 100644 --- a/io.go +++ b/io.go @@ -189,7 +189,11 @@ func Copy(sourceMedium Medium, sourcePath string, destinationMedium Medium, dest if err != nil { return core.E("io.Copy", core.Concat("read failed: ", sourcePath), err) } - if err := destinationMedium.Write(destinationPath, content); err != nil { + mode := fs.FileMode(0644) + if info, err := sourceMedium.Stat(sourcePath); err == nil { + mode = info.Mode() + } + if err := destinationMedium.WriteMode(destinationPath, content, mode); err != nil { return core.E("io.Copy", core.Concat("write failed: ", destinationPath), err) } return nil @@ -271,9 +275,9 @@ func (medium *MemoryMedium) Write(path, content string) error { } // Example: _ = io.NewMemoryMedium().WriteMode("keys/private.key", "secret", 0600) -func (medium *MemoryMedium) WriteMode(path, content string, mode fs.FileMode) error { +func (medium *MemoryMedium) WriteMode(filePath, content string, mode fs.FileMode) error { // Verify no ancestor directory component is stored as a file. - ancestor := path.Dir(path) + ancestor := path.Dir(filePath) for ancestor != "." && ancestor != "" { if _, ok := medium.fileContents[ancestor]; ok { return core.E("io.MemoryMedium.WriteMode", core.Concat("ancestor path is a file: ", ancestor), fs.ErrExist) @@ -284,10 +288,13 @@ func (medium *MemoryMedium) WriteMode(path, content string, mode fs.FileMode) er } ancestor = next } - medium.ensureAncestorDirectories(path) - medium.fileContents[path] = content - medium.fileModes[path] = mode - medium.modificationTimes[path] = time.Now() + if _, ok := medium.directories[filePath]; ok { + return core.E("io.MemoryMedium.WriteMode", core.Concat("path is a directory: ", filePath), fs.ErrExist) + } + medium.ensureAncestorDirectories(filePath) + medium.fileContents[filePath] = content + medium.fileModes[filePath] = mode + medium.modificationTimes[filePath] = time.Now() return nil } @@ -537,6 +544,9 @@ func (writeCloser *MemoryWriteCloser) Write(data []byte) (int, error) { } func (writeCloser *MemoryWriteCloser) Close() error { + if _, ok := writeCloser.medium.directories[writeCloser.path]; ok { + return core.E("io.MemoryWriteCloser.Close", core.Concat("path is a directory: ", writeCloser.path), fs.ErrExist) + } writeCloser.medium.ensureAncestorDirectories(writeCloser.path) writeCloser.medium.fileContents[writeCloser.path] = string(writeCloser.data) writeCloser.medium.fileModes[writeCloser.path] = writeCloser.mode diff --git a/node/node.go b/node/node.go index 8d00c40..e8b095b 100644 --- a/node/node.go +++ b/node/node.go @@ -22,6 +22,8 @@ import ( // Example: nodeTree.AddData("config/app.yaml", []byte("port: 8080")) // Example: snapshot, _ := nodeTree.ToTar() // Example: restored, _ := node.FromTar(snapshot) +// Note: Node is not goroutine-safe. All methods must be called from a single goroutine, +// or the caller must provide external synchronisation. type Node struct { files map[string]*dataFile } @@ -152,7 +154,13 @@ func (node *Node) Walk(root string, walkFunc fs.WalkDirFunc, options WalkOptions if walkResult == nil && options.MaxDepth > 0 && entry != nil && entry.IsDir() && entryPath != root { relativePath := core.TrimPrefix(entryPath, root) relativePath = core.TrimPrefix(relativePath, "/") - depth := len(core.Split(relativePath, "/")) + parts := core.Split(relativePath, "/") + depth := 0 + for _, part := range parts { + if part != "" { + depth++ + } + } if depth >= options.MaxDepth { return fs.SkipDir } @@ -174,23 +182,26 @@ func (node *Node) ReadFile(name string) ([]byte, error) { return result, nil } -// Example: _ = nodeTree.CopyFile("config/app.yaml", "backup/app.yaml", 0644) -func (node *Node) CopyFile(sourcePath, destinationPath string, permissions fs.FileMode) error { +// ExportFile writes a node file to the local filesystem. It operates on coreio.Local directly +// and is intentionally local-only — use CopyTo for Medium-agnostic transfers. +// Example: _ = nodeTree.ExportFile("config/app.yaml", "backup/app.yaml", 0644) +func (node *Node) ExportFile(sourcePath, destinationPath string, permissions fs.FileMode) error { sourcePath = core.TrimPrefix(sourcePath, "/") file, ok := node.files[sourcePath] if !ok { info, err := node.Stat(sourcePath) if err != nil { - return core.E("node.CopyFile", core.Concat("source not found: ", sourcePath), fs.ErrNotExist) + return core.E("node.ExportFile", core.Concat("source not found: ", sourcePath), fs.ErrNotExist) } if info.IsDir() { - return core.E("node.CopyFile", core.Concat("source is a directory: ", sourcePath), fs.ErrInvalid) + return core.E("node.ExportFile", core.Concat("source is a directory: ", sourcePath), fs.ErrInvalid) } - return core.E("node.CopyFile", core.Concat("source not found: ", sourcePath), fs.ErrNotExist) + // unreachable: Stat only succeeds for directories when file is absent + return core.E("node.ExportFile", core.Concat("source not found: ", sourcePath), fs.ErrNotExist) } parent := core.PathDir(destinationPath) if parent != "." && parent != "" && parent != destinationPath && !coreio.Local.IsDir(parent) { - return &fs.PathError{Op: "copyfile", Path: destinationPath, Err: fs.ErrNotExist} + return core.E("node.ExportFile", core.Concat("parent directory not found: ", destinationPath), fs.ErrNotExist) } return coreio.Local.WriteMode(destinationPath, string(file.content), permissions) } @@ -401,18 +412,42 @@ func (node *Node) DeleteAll(filePath string) error { } // Example: _ = nodeTree.Rename("drafts/todo.txt", "archive/todo.txt") +// Example: _ = nodeTree.Rename("drafts", "archive") func (node *Node) Rename(oldPath, newPath string) error { oldPath = core.TrimPrefix(oldPath, "/") newPath = core.TrimPrefix(newPath, "/") - file, ok := node.files[oldPath] - if !ok { - return core.E("node.Rename", core.Concat("path not found: ", oldPath), fs.ErrNotExist) + if file, ok := node.files[oldPath]; ok { + file.name = newPath + node.files[newPath] = file + delete(node.files, oldPath) + return nil } - file.name = newPath - node.files[newPath] = file - delete(node.files, oldPath) + // Directory rename: batch-rename all entries that share the prefix. + oldPrefix := oldPath + "/" + newPrefix := newPath + "/" + renamed := 0 + toAdd := make(map[string]*dataFile) + toDelete := make([]string, 0) + for filePath, file := range node.files { + if core.HasPrefix(filePath, oldPrefix) { + updatedPath := core.Concat(newPrefix, core.TrimPrefix(filePath, oldPrefix)) + file.name = updatedPath + toAdd[updatedPath] = file + toDelete = append(toDelete, filePath) + renamed++ + } + } + for _, p := range toDelete { + delete(node.files, p) + } + for p, f := range toAdd { + node.files[p] = f + } + if renamed == 0 { + return core.E("node.Rename", core.Concat("path not found: ", oldPath), fs.ErrNotExist) + } return nil } diff --git a/node/node_test.go b/node/node_test.go index 5d2a21b..f0b46da 100644 --- a/node/node_test.go +++ b/node/node_test.go @@ -313,12 +313,12 @@ func TestNode_Walk_Good(t *testing.T) { }) } -func TestNode_CopyFile_Good(t *testing.T) { +func TestNode_ExportFile_Good(t *testing.T) { nodeTree := New() nodeTree.AddData("foo.txt", []byte("foo")) destinationPath := core.Path(t.TempDir(), "test.txt") - err := nodeTree.CopyFile("foo.txt", destinationPath, 0644) + err := nodeTree.ExportFile("foo.txt", destinationPath, 0644) require.NoError(t, err) content, err := coreio.Local.Read(destinationPath) @@ -326,24 +326,24 @@ func TestNode_CopyFile_Good(t *testing.T) { assert.Equal(t, "foo", content) } -func TestNode_CopyFile_Bad(t *testing.T) { +func TestNode_ExportFile_Bad(t *testing.T) { nodeTree := New() destinationPath := core.Path(t.TempDir(), "test.txt") - err := nodeTree.CopyFile("nonexistent.txt", destinationPath, 0644) + err := nodeTree.ExportFile("nonexistent.txt", destinationPath, 0644) assert.Error(t, err) nodeTree.AddData("foo.txt", []byte("foo")) - err = nodeTree.CopyFile("foo.txt", "/nonexistent_dir/test.txt", 0644) + err = nodeTree.ExportFile("foo.txt", "/nonexistent_dir/test.txt", 0644) assert.Error(t, err) } -func TestNode_CopyFile_DirectorySource_Bad(t *testing.T) { +func TestNode_ExportFile_DirectorySource_Bad(t *testing.T) { nodeTree := New() nodeTree.AddData("bar/baz.txt", []byte("baz")) destinationPath := core.Path(t.TempDir(), "test.txt") - err := nodeTree.CopyFile("bar", destinationPath, 0644) + err := nodeTree.ExportFile("bar", destinationPath, 0644) assert.Error(t, err) } diff --git a/sigil/crypto_sigil.go b/sigil/crypto_sigil.go index 58ca1e2..71ecdc9 100644 --- a/sigil/crypto_sigil.go +++ b/sigil/crypto_sigil.go @@ -182,11 +182,28 @@ func (obfuscator *ShuffleMaskObfuscator) deriveMask(entropy []byte, length int) // Example: &sigil.ShuffleMaskObfuscator{}, // Example: ) type ChaChaPolySigil struct { - Key []byte - Obfuscator PreObfuscator + key []byte + obfuscator PreObfuscator randomReader goio.Reader } +// Example: key := cipherSigil.Key() +func (s *ChaChaPolySigil) Key() []byte { + result := make([]byte, len(s.key)) + copy(result, s.key) + return result +} + +// Example: ob := cipherSigil.Obfuscator() +func (s *ChaChaPolySigil) Obfuscator() PreObfuscator { + return s.obfuscator +} + +// Example: cipherSigil.SetObfuscator(nil) +func (s *ChaChaPolySigil) SetObfuscator(obfuscator PreObfuscator) { + s.obfuscator = obfuscator +} + // Example: cipherSigil, _ := sigil.NewChaChaPolySigil([]byte("0123456789abcdef0123456789abcdef"), nil) // Example: ciphertext, _ := cipherSigil.In([]byte("payload")) // Example: plaintext, _ := cipherSigil.Out(ciphertext) @@ -203,27 +220,27 @@ func NewChaChaPolySigil(key []byte, obfuscator PreObfuscator) (*ChaChaPolySigil, } return &ChaChaPolySigil{ - Key: keyCopy, - Obfuscator: obfuscator, + key: keyCopy, + obfuscator: obfuscator, randomReader: rand.Reader, }, nil } -func (sigil *ChaChaPolySigil) In(data []byte) ([]byte, error) { - if sigil.Key == nil { +func (s *ChaChaPolySigil) In(data []byte) ([]byte, error) { + if s.key == nil { return nil, NoKeyConfiguredError } if data == nil { return nil, nil } - aead, err := chacha20poly1305.NewX(sigil.Key) + aead, err := chacha20poly1305.NewX(s.key) if err != nil { return nil, core.E("sigil.ChaChaPolySigil.In", "create cipher", err) } nonce := make([]byte, aead.NonceSize()) - reader := sigil.randomReader + reader := s.randomReader if reader == nil { reader = rand.Reader } @@ -232,8 +249,8 @@ func (sigil *ChaChaPolySigil) In(data []byte) ([]byte, error) { } obfuscated := data - if sigil.Obfuscator != nil { - obfuscated = sigil.Obfuscator.Obfuscate(data, nonce) + if s.obfuscator != nil { + obfuscated = s.obfuscator.Obfuscate(data, nonce) } ciphertext := aead.Seal(nonce, nonce, obfuscated, nil) @@ -241,15 +258,15 @@ func (sigil *ChaChaPolySigil) In(data []byte) ([]byte, error) { return ciphertext, nil } -func (sigil *ChaChaPolySigil) Out(data []byte) ([]byte, error) { - if sigil.Key == nil { +func (s *ChaChaPolySigil) Out(data []byte) ([]byte, error) { + if s.key == nil { return nil, NoKeyConfiguredError } if data == nil { return nil, nil } - aead, err := chacha20poly1305.NewX(sigil.Key) + aead, err := chacha20poly1305.NewX(s.key) if err != nil { return nil, core.E("sigil.ChaChaPolySigil.Out", "create cipher", err) } @@ -270,8 +287,8 @@ func (sigil *ChaChaPolySigil) Out(data []byte) ([]byte, error) { } plaintext := obfuscated - if sigil.Obfuscator != nil { - plaintext = sigil.Obfuscator.Deobfuscate(obfuscated, nonce) + if s.obfuscator != nil { + plaintext = s.obfuscator.Deobfuscate(obfuscated, nonce) } if len(plaintext) == 0 { diff --git a/sigil/crypto_sigil_test.go b/sigil/crypto_sigil_test.go index 41a20d2..d4f96a8 100644 --- a/sigil/crypto_sigil_test.go +++ b/sigil/crypto_sigil_test.go @@ -145,8 +145,8 @@ func TestCryptoSigil_NewChaChaPolySigil_Good(t *testing.T) { cipherSigil, err := NewChaChaPolySigil(key, nil) require.NoError(t, err) assert.NotNil(t, cipherSigil) - assert.Equal(t, key, cipherSigil.Key) - assert.NotNil(t, cipherSigil.Obfuscator) + assert.Equal(t, key, cipherSigil.Key()) + assert.NotNil(t, cipherSigil.Obfuscator()) } func TestCryptoSigil_NewChaChaPolySigil_KeyIsCopied_Good(t *testing.T) { @@ -159,7 +159,7 @@ func TestCryptoSigil_NewChaChaPolySigil_KeyIsCopied_Good(t *testing.T) { require.NoError(t, err) key[0] ^= 0xFF - assert.Equal(t, original, cipherSigil.Key) + assert.Equal(t, original, cipherSigil.Key()) } func TestCryptoSigil_NewChaChaPolySigil_ShortKey_Bad(t *testing.T) { @@ -184,7 +184,7 @@ func TestCryptoSigil_NewChaChaPolySigil_CustomObfuscator_Good(t *testing.T) { ob := &ShuffleMaskObfuscator{} cipherSigil, err := NewChaChaPolySigil(key, ob) require.NoError(t, err) - assert.Equal(t, ob, cipherSigil.Obfuscator) + assert.Equal(t, ob, cipherSigil.Obfuscator()) } func TestCryptoSigil_NewChaChaPolySigil_CustomObfuscatorNil_Good(t *testing.T) { @@ -193,7 +193,7 @@ func TestCryptoSigil_NewChaChaPolySigil_CustomObfuscatorNil_Good(t *testing.T) { cipherSigil, err := NewChaChaPolySigil(key, nil) require.NoError(t, err) - assert.IsType(t, &XORObfuscator{}, cipherSigil.Obfuscator) + assert.IsType(t, &XORObfuscator{}, cipherSigil.Obfuscator()) } func TestCryptoSigil_NewChaChaPolySigil_CustomObfuscator_InvalidKey_Bad(t *testing.T) { @@ -351,7 +351,7 @@ func TestCryptoSigil_ChaChaPolySigil_NoObfuscator_Good(t *testing.T) { _, _ = rand.Read(key) cipherSigil, _ := NewChaChaPolySigil(key, nil) - cipherSigil.Obfuscator = nil + cipherSigil.SetObfuscator(nil) plaintext := []byte("raw encryption without pre-obfuscation") ciphertext, err := cipherSigil.In(plaintext) diff --git a/store/medium.go b/store/medium.go index c107e11..6c0f0bc 100644 --- a/store/medium.go +++ b/store/medium.go @@ -76,6 +76,8 @@ func (medium *Medium) Write(entryPath, content string) error { } // Example: _ = medium.WriteMode("app/theme", "midnight", 0600) +// Note: mode is not persisted — the SQLite store has no entry_mode column. +// Use Write when mode is irrelevant; WriteMode satisfies the Medium interface only. func (medium *Medium) WriteMode(entryPath, content string, mode fs.FileMode) error { return medium.Write(entryPath, content) } @@ -144,23 +146,14 @@ func (medium *Medium) List(entryPath string) ([]fs.DirEntry, error) { group, key := splitGroupKeyPath(entryPath) if group == "" { - rows, err := medium.keyValueStore.database.Query("SELECT DISTINCT group_name FROM entries ORDER BY group_name") + groups, err := medium.keyValueStore.ListGroups() if err != nil { - return nil, core.E("store.List", "query groups", err) + return nil, err } - defer rows.Close() - - var entries []fs.DirEntry - for rows.Next() { - var groupName string - if err := rows.Scan(&groupName); err != nil { - return nil, core.E("store.List", "scan", err) - } + entries := make([]fs.DirEntry, 0, len(groups)) + for _, groupName := range groups { entries = append(entries, &keyValueDirEntry{name: groupName, isDir: true}) } - if err := rows.Err(); err != nil { - return nil, core.E("store.List", "rows", err) - } return entries, nil } diff --git a/store/store.go b/store/store.go index 2b5efc2..31550bc 100644 --- a/store/store.go +++ b/store/store.go @@ -112,6 +112,28 @@ func (keyValueStore *KeyValueStore) DeleteGroup(group string) error { return nil } +// Example: groups, _ := keyValueStore.ListGroups() +func (keyValueStore *KeyValueStore) ListGroups() ([]string, error) { + rows, err := keyValueStore.database.Query("SELECT DISTINCT group_name FROM entries ORDER BY group_name") + if err != nil { + return nil, core.E("store.ListGroups", "query groups", err) + } + defer rows.Close() + + var groups []string + for rows.Next() { + var groupName string + if err := rows.Scan(&groupName); err != nil { + return nil, core.E("store.ListGroups", "scan", err) + } + groups = append(groups, groupName) + } + if err := rows.Err(); err != nil { + return nil, core.E("store.ListGroups", "rows", err) + } + return groups, nil +} + // Example: values, _ := keyValueStore.GetAll("app") func (keyValueStore *KeyValueStore) GetAll(group string) (map[string]string, error) { rows, err := keyValueStore.database.Query("SELECT entry_key, entry_value FROM entries WHERE group_name = ?", group)