Repository navigation
Expand file tree
/
Copy pathfiling.go
More file actions
364 lines (343 loc) · 11.2 KB
/
Copy pathfiling.go
File metadata and controls
364 lines (343 loc) · 11.2 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
package database
import (
"database/sql"
"fmt"
"path"
"slices"
"sort"
"strings"
"github.com/bashhack/sesh/internal/vault"
)
// Changing where entries are filed: their folder and tags. These don't
// touch secrets, and an entry's update time stays, since its content
// doesn't change. Each change is one transaction, all or none, and one
// "modify" audit event per entry it changes.
// noSuch is a folder or tag no entry has. It wraps vault.ErrNotFound.
type noSuch struct{ what, name string }
func (e noSuch) Error() string { return fmt.Sprintf("there's no %s %q", e.what, e.name) }
func (e noSuch) Unwrap() error { return vault.ErrNotFound }
// entryIDs returns the row ids of keys, each once, or an error wrapping
// vault.ErrNotFound for the first missing one.
func entryIDs(tx *sql.Tx, keys []vault.Key) (map[vault.Key]int64, error) {
ids := make(map[vault.Key]int64, len(keys))
for _, k := range keys {
if _, ok := ids[k]; ok {
continue
}
var id int64
err := tx.QueryRow(`SELECT id FROM entries WHERE kind = ? AND service = ? AND username = ?`,
string(k.Kind), k.Service, k.Username).Scan(&id)
if err == sql.ErrNoRows {
return nil, notFound(k)
}
if err != nil {
return nil, fmt.Errorf("look up %s: %w", k, err)
}
ids[k] = id
}
return ids, nil
}
// changeEach runs change on each of keys' rows in one transaction, all or
// none, and audits the entries it reports changing, with detail. It
// returns how many changed.
func (s *Store) changeEach(keys []vault.Key, detail string, change func(tx *sql.Tx, id int64) (bool, error)) (int, error) {
var changed []vault.Key
err := s.inTx(func(tx *sql.Tx) error {
ids, err := entryIDs(tx, keys)
if err != nil {
return err
}
changed = changed[:0]
for _, k := range keys {
id, ok := ids[k]
if !ok {
continue // named twice
}
delete(ids, k)
did, err := change(tx, id)
if err != nil {
return fmt.Errorf("change %s: %w", k, err)
}
if did {
changed = append(changed, k)
}
}
return nil
})
if err != nil {
return 0, err
}
for _, k := range changed {
s.audit("modify", k.String(), detail)
}
return len(changed), nil
}
// rowsChanged reports whether res changed a row.
func rowsChanged(res sql.Result, err error) (bool, error) {
if err != nil {
return false, err
}
n, err := res.RowsAffected()
return n > 0, err
}
// MoveToFolder files the entries keys name in folder ("" for none),
// returning how many moved; those already there don't count.
func (s *Store) MoveToFolder(keys []vault.Key, folder string) (int, error) {
if err := vault.CheckFolder(folder); err != nil {
return 0, err
}
return s.changeEach(keys, "Folder", func(tx *sql.Tx, id int64) (bool, error) {
return rowsChanged(tx.Exec(`UPDATE entries SET folder = ? WHERE id = ? AND folder <> ?`, folder, id, folder))
})
}
// AddTag tags the entries keys name, returning how many didn't have it.
func (s *Store) AddTag(keys []vault.Key, tag string) (int, error) {
if err := vault.CheckTag(tag); err != nil {
return 0, err
}
return s.changeEach(keys, "Tag", func(tx *sql.Tx, id int64) (bool, error) {
return rowsChanged(tx.Exec(`INSERT OR IGNORE INTO entry_tags (entry_id, tag) VALUES (?, ?)`, id, tag))
})
}
// RemoveTag takes tag off the entries keys name, returning how many had it.
func (s *Store) RemoveTag(keys []vault.Key, tag string) (int, error) {
if err := vault.CheckTag(tag); err != nil {
return 0, err
}
return s.changeEach(keys, "Untag", func(tx *sql.Tx, id int64) (bool, error) {
return rowsChanged(tx.Exec(`DELETE FROM entry_tags WHERE entry_id = ? AND tag = ?`, id, tag))
})
}
// CheckFolderRename refuses renaming folder from to to without looking at
// the vault: a name either can't have, the same name, or a folder under
// from, which would move from into itself.
func CheckFolderRename(from, to string) error {
switch {
case from == "":
return fmt.Errorf("name the folder to rename")
case to == "":
return fmt.Errorf("name the folder's new name; to take entries out of a folder: sesh folder move \"\" <id>…")
case from == to:
return fmt.Errorf("the folder is already called %q", to)
case vault.InFolder(to, from):
return fmt.Errorf("can't move folder %q into a folder under itself (%q)", from, to)
}
if err := vault.CheckFolder(from); err != nil {
return err
}
return vault.CheckFolder(to)
}
// RenameFolder renames folder from, and the folders under it, to to,
// returning how many entries moved. When to already held other entries,
// the two merge, and merged says so. It's refused when a subfolder's new
// name would be too long.
func (s *Store) RenameFolder(from, to string) (n int, merged bool, err error) {
if err := CheckFolderRename(from, to); err != nil {
return 0, false, err
}
var moved []vault.Key
err = s.inTx(func(tx *sql.Tx) error {
inFrom, err := keysInFolder(tx, from)
if err != nil {
return err
}
if len(inFrom) == 0 {
return noSuch{"folder", from}
}
// The longest new name: to, then what follows from.
var longest sql.NullString
var length int
err = tx.QueryRow(`SELECT kind || '/' || service || CASE username WHEN '' THEN '' ELSE '/' || username END,
length(?) + length(folder) - length(?) AS n
FROM entries WHERE folder = ? OR substr(folder, 1, length(?) + 1) = ? || '/'
ORDER BY n DESC LIMIT 1`, to, from, from, from, from).Scan(&longest, &length)
if err != nil {
return err
}
if length > vault.MaxFolderLength {
return fmt.Errorf("renaming folder %q to %q would make the folder of %s %d characters long; the most is %d", from, to, longest.String, length, vault.MaxFolderLength)
}
inTo, err := keysInFolder(tx, to)
if err != nil {
return err
}
// to may hold from (renaming p/q to p): only other entries merge.
for _, k := range inTo {
if !slices.Contains(inFrom, k) {
merged = true
break
}
}
// from's own entries go to to; one in from/x goes to to/x.
if _, err := tx.Exec(`UPDATE entries SET folder = ? || substr(folder, length(?) + 1)
WHERE folder = ? OR substr(folder, 1, length(?) + 1) = ? || '/'`, to, from, from, from, from); err != nil {
return err
}
moved = inFrom
return nil
})
if err != nil {
return 0, false, err
}
for _, k := range moved {
s.audit("modify", k.String(), "Folder")
}
return len(moved), merged, nil
}
// keysInFolder returns the entries in folder or one under it.
func keysInFolder(tx *sql.Tx, folder string) ([]vault.Key, error) {
return queryKeys(tx, `SELECT kind, service, username FROM entries
WHERE folder = ? OR substr(folder, 1, length(?) + 1) = ? || '/'`, folder, folder, folder)
}
// CheckTagRename refuses renaming tag from to to without looking at the
// vault: a name either can't have, or the same name.
func CheckTagRename(from, to string) error {
if from == to {
return fmt.Errorf("the tag is already called %q", to)
}
if err := vault.CheckTag(from); err != nil {
return err
}
return vault.CheckTag(to)
}
// RenameTag renames tag from to to on every entry, returning how many
// entries had it. When to was already in use, the two merge, and merged
// says so; an entry with both keeps one.
func (s *Store) RenameTag(from, to string) (n int, merged bool, err error) {
if err := CheckTagRename(from, to); err != nil {
return 0, false, err
}
var tagged []vault.Key
err = s.inTx(func(tx *sql.Tx) error {
var err error
tagged, err = queryKeys(tx, `SELECT e.kind, e.service, e.username FROM entries e JOIN entry_tags t ON t.entry_id = e.id WHERE t.tag = ?`, from)
if err != nil {
return err
}
if len(tagged) == 0 {
return noSuch{"tag", from}
}
if err := tx.QueryRow(`SELECT EXISTS (SELECT 1 FROM entry_tags WHERE tag = ?)`, to).Scan(&merged); err != nil {
return err
}
if _, err := tx.Exec(`INSERT OR IGNORE INTO entry_tags (entry_id, tag) SELECT entry_id, ? FROM entry_tags WHERE tag = ?`, to, from); err != nil {
return err
}
_, err = tx.Exec(`DELETE FROM entry_tags WHERE tag = ?`, from)
return err
})
if err != nil {
return 0, false, err
}
for _, k := range tagged {
s.audit("modify", k.String(), "Tag")
}
return len(tagged), merged, nil
}
// queryKeys returns the keys q's rows (kind, service, username) name.
func queryKeys(tx *sql.Tx, q string, args ...any) (_ []vault.Key, err error) {
rows, err := tx.Query(q, args...)
if err != nil {
return nil, err
}
defer func() {
if cerr := rows.Close(); err == nil {
err = cerr
}
}()
var keys []vault.Key
for rows.Next() {
var k vault.Key
var kind string
if err := rows.Scan(&kind, &k.Service, &k.Username); err != nil {
return nil, err
}
k.Kind = vault.Kind(kind)
keys = append(keys, k)
}
return keys, rows.Err()
}
// FolderCount is a folder and how many entries it holds: Entries in it
// alone, Total with its subfolders'. Name "" counts the entries in no
// folder.
type FolderCount struct {
Name string
Entries, Total int
}
// Folders returns every folder, with the folders above them that hold no
// entries of their own, ordered part by part so subfolders follow their
// folder. The entries in no folder come first, when there are any.
func (s *Store) Folders() ([]FolderCount, error) {
rows, err := s.db.Query(`SELECT folder, count(*) FROM entries GROUP BY folder`)
if err != nil {
return nil, fmt.Errorf("list folders: %w", err)
}
own := map[string]int{}
for rows.Next() {
var folder string
var n int
if err := rows.Scan(&folder, &n); err != nil {
_ = rows.Close() //nolint:errcheck // already failing
return nil, fmt.Errorf("list folders: %w", err)
}
own[folder] = n
}
if err := rows.Close(); err != nil {
return nil, fmt.Errorf("list folders: %w", err)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("list folders: %w", err)
}
total := map[string]int{}
for folder, n := range own {
if folder == "" {
total[""] += n
continue
}
for p := folder; p != "."; p = path.Dir(p) {
total[p] += n
}
}
out := make([]FolderCount, 0, len(total))
for name, t := range total {
out = append(out, FolderCount{Name: name, Entries: own[name], Total: t})
}
slices.SortFunc(out, func(a, b FolderCount) int {
if a.Name == "" || b.Name == "" {
return strings.Compare(a.Name, b.Name)
}
return slices.Compare(strings.Split(a.Name, "/"), strings.Split(b.Name, "/"))
})
return out, nil
}
// TagCount is a tag and how many entries have it.
type TagCount struct {
Name string
Entries int
}
// Tags returns every tag in name order, and how many entries have none.
func (s *Store) Tags() (tags []TagCount, untagged int, err error) {
rows, err := s.db.Query(`SELECT tag, count(*) FROM entry_tags GROUP BY tag`)
if err != nil {
return nil, 0, fmt.Errorf("list tags: %w", err)
}
for rows.Next() {
var t TagCount
if err := rows.Scan(&t.Name, &t.Entries); err != nil {
_ = rows.Close() //nolint:errcheck // already failing
return nil, 0, fmt.Errorf("list tags: %w", err)
}
tags = append(tags, t)
}
if err := rows.Close(); err != nil {
return nil, 0, fmt.Errorf("list tags: %w", err)
}
if err := rows.Err(); err != nil {
return nil, 0, fmt.Errorf("list tags: %w", err)
}
sort.Slice(tags, func(i, j int) bool { return tags[i].Name < tags[j].Name })
if err := s.db.QueryRow(`SELECT count(*) FROM entries WHERE NOT EXISTS (SELECT 1 FROM entry_tags WHERE entry_id = entries.id)`).Scan(&untagged); err != nil {
return nil, 0, fmt.Errorf("list tags: %w", err)
}
return tags, untagged, nil
}