diff --git a/AGENTS.md b/AGENTS.md index 5e177c70..94fb9449 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -117,6 +117,16 @@ nothing, so the extension only works after the `browser install` above. ## Changes +- **What a released sesh wrote keeps working.** A change to how the vault, an + export or a sync folder is stored comes with a way to read what earlier + releases wrote: for the vault, a new upgrade step (`migrateVN` in + `internal/database/schema.go`, with `currentSchemaVersion` bumped), never an + edit to an old one. `internal/compat` keeps files written by released + versions, made-up, and tests that this sesh still reads them. A release + that changes a format adds its own (`testdata//`, made with that + release, as `testdata/v0.1.1/make.sh` does) and tests for them. Never edit + or remake a released version's files, or loosen these tests, to get a + failing one to pass: the failure is the point. - **One pull request per change**, each on its own branch (a git worktree per branch keeps them apart). Each is reviewed before it's merged, and CI must pass. diff --git a/docs/USAGE_AND_CONFIGURATION.md b/docs/USAGE_AND_CONFIGURATION.md index b31696db..68cecab4 100644 --- a/docs/USAGE_AND_CONFIGURATION.md +++ b/docs/USAGE_AND_CONFIGURATION.md @@ -435,7 +435,7 @@ sesh can't reset it for you: the master password derives the vault's key, sesh d sesh recover ``` -- **Restore from an encrypted export**, if you made one. Start a new vault (below), then import the export. It asks for the export's own password, which you chose when exporting: +- **Restore from an encrypted export**, if you made one. Start a new vault (below), then import the export (entries only: the trash and earlier secrets aren't in it). It asks for the export's own password, which you chose when exporting: ```bash sesh import --from sesh backup.enc @@ -745,7 +745,7 @@ What to do FAIL: 2 problems, 2 warnings ``` -It names every entry it can't read, and never shows a secret. It exits 1 when the config doesn't load, there's no vault, the vault file can't be read, the vault can't be unlocked (a wrong master password, say), or the file, an entry, the trash, an earlier secret, or the recovery key has a problem; warnings exit 0. Whatever stops the vault part, the setup's steps and the tally still show. If the file is damaged but every entry still reads, it says to export them now and start a new vault from that. If nothing can unlock the vault without asking (no `SESH_MASTER_PASSWORD`, no unlocked agent) and there's no terminal to ask at, as in a script, the setup is still checked, the last line says the vault wasn't, and it exits 0 unless the setup has a problem. If the agent locks or another command changes the master password while it checks, it says to run it again rather than blaming any entry. Run it when something seems off, after moving or restoring the vault, after a crash, or now and then. It writes nothing while it checks, then one `doctor` event to the audit log (not when the file itself is damaged). +It names every entry it can't read, and never shows a secret. It exits 1 when the config doesn't load, there's no vault, the vault file can't be read, the vault can't be unlocked (a wrong master password, say), or the file, an entry, the trash, an earlier secret, or the recovery key has a problem; warnings exit 0. Whatever stops the vault part, the setup's steps and the tally still show. If the file is damaged but every entry still reads, it says to export them now and start a new vault from that (the trash and earlier secrets stay in the old file). If nothing can unlock the vault without asking (no `SESH_MASTER_PASSWORD`, no unlocked agent) and there's no terminal to ask at, as in a script, the setup is still checked, the last line says the vault wasn't, and it exits 0 unless the setup has a problem. If the agent locks or another command changes the master password while it checks, it says to run it again rather than blaming any entry. Run it when something seems off, after moving or restoring the vault, after a crash, or now and then. It writes nothing while it checks, then one `doctor` event to the audit log (not when the file itself is damaged). ### Backups @@ -825,7 +825,7 @@ An automatic sync with nothing to do isn't written to the [audit log](#the-audit - **What a sync tool does is handled:** its copies of a file (Dropbox's "conflicted copy", Syncthing's, iCloud's "name 2") are merged and removed; a file it loses is written again, never taken for a deletion; an older copy it puts back is ignored, so an old password can't quietly return. - **iCloud Drive can keep files in the cloud** to save space ("Optimise Mac Storage"): sesh waits for those, says how many in the summary, and writes nothing over them. Keep the sync folder downloaded: in Finder, Control-click it and choose Keep Downloaded (macOS 15 or later). - **Each computer is named** in summaries and in history by its own name; `sync.name` changes it. -- **Joining needs a new or empty vault,** or one joining again the folder it left: a vault's own entries, never synced with the folder, would bring back, as edits, any deleted elsewhere. To bring a vault's entries along: `sesh export --file F`, move the vault's files aside (the vault, any `-wal` and `-shm` files beside it, and `touchid.key`), `sesh sync join `, then `sesh import --from sesh F`. +- **Joining needs a new or empty vault,** or one joining again the folder it left: a vault's own entries, never synced with the folder, would bring back, as edits, any deleted elsewhere. To bring a vault's entries along: `sesh export --file F`, move the vault's files aside (the vault, any `-wal` and `-shm` files beside it, and `touchid.key`), `sesh sync join `, then `sesh import --from sesh F`; the trash and earlier secrets stay with the vault moved aside. - **A master password changed on one computer** reaches the others: they ask for it at their next `sesh sync`, at a terminal, and switch to it (see [Changing your master password](#changing-your-master-password-sesh-passwd)). - **Joining again** works for a vault that left the folder, even one whose entries were all deleted since. One whose master password changed while it was away is switched to the folder's as it joins (it asks for the folder's, then its own): its own change isn't kept, so to change every computer's password, change it while syncing. - **`sync.dir` set by hand** (`sesh config set sync.dir`, or `SESH_SYNC_DIR`) to a folder the vault hasn't synced with is held to the same rule as joining: a vault with entries of its own that synced with another folder, or never synced, doesn't sync with one holding entry files (even ones it can't read), as that would bring back what was deleted in either; a new, empty folder starts afresh. A folder inside the vault's own, or holding the vault, is refused. @@ -864,7 +864,7 @@ An export to a file, in any format, doesn't replace an existing one without `--f Encrypted exports use Argon2id and AES-256-GCM, as the vault does. The file holds its own salt and Argon2id settings, so it opens on any computer, whatever its master password, given the export password. -Every export holds everything about each entry (CSV leaves out file entries, and says how many; JSON and the encrypted export include them): its kind, service name, username, secret, times, settings, folder, tags, URL, notes, and custom fields (secret ones included). A TOTP entry's settings (algorithm, digits, period, issuer) decide which codes are right, so they come back with it on import. In JSON they're the `settings` field; in CSV, a `settings` column holding the same JSON. +Every export holds everything about each entry (CSV leaves out file entries, and says how many; JSON and the encrypted export include them): its kind, service name, username, secret, times, settings, folder, tags, URL, notes, and custom fields (secret ones included). Its earlier secrets (its [history](#password-history)) aren't included. A TOTP entry's settings (algorithm, digits, period, issuer) decide which codes are right, so they come back with it on import. In JSON they're the `settings` field; in CSV, a `settings` column holding the same JSON. ### Changing your master password (`sesh passwd`) @@ -1940,7 +1940,7 @@ sesh add --help | Message | What it means | What to do | |---|---|---| | "this vault doesn't sync: …" | `sync.dir` isn't set | `sesh sync init` makes a sync folder from this vault; `sesh sync join ` joins one | -| "this vault already has entries of its own, never synced with this folder. Joining takes a new or empty vault, …" (from `sesh sync join`) | Joining would bring back, as edits, entries deleted on the other computers | Follow the steps it gives: export, move the vault's files aside, join, import | +| "this vault already has entries of its own, never synced with this folder. Joining takes a new or empty vault, …" (from `sesh sync join`) | Joining would bring back, as edits, entries deleted on the other computers | Follow the steps it gives: export, move the vault's files aside, join, import (the trash and earlier secrets stay with the vault moved aside) | | "the master password was changed on another computer: …", or "⚠️ Sync: the master password was changed on another computer; nothing syncs until it's taken: sesh sync, or the Sync row in sesh ui's settings." | Another computer changed it; this vault still opens with its own, and doesn't sync | Run `sesh sync` at a terminal, or in `sesh ui` take it from the Sync row in settings and safety: each asks for this vault's password, then the new one. Forgotten your own? `sesh recover` takes the new one with the recovery key | | "⚠️ Sync: the master password was changed here and on another computer at once, and the other's is kept; nothing syncs until it's taken: sesh sync, or the Sync row in sesh ui's settings." | Two changes were made from the same master password, each computer's sync tool not yet showing the other's; the other one is kept on every computer | Run `sesh sync` at a terminal, or take it from the Sync row in `sesh ui`, and type the other change's password. If no computer of yours made it, `sesh passwd` puts yours back | | "this computer's master password change hasn't reached the sync folder yet: …" | The password was changed here with the folder out of reach | `sesh sync` sends it, as does any command that syncs on its own (`sync.auto`) once the folder is back | diff --git a/internal/compat/compat_test.go b/internal/compat/compat_test.go new file mode 100644 index 00000000..c8886bb6 --- /dev/null +++ b/internal/compat/compat_test.go @@ -0,0 +1,347 @@ +// Package compat holds files written by released versions of sesh, and +// tests that this one still reads them: a vault, an encrypted export and a +// sync folder, each with made-up entries. A change to how any of them is +// stored needs a way to read what a release wrote; these tests fail +// without one. +package compat + +import ( + "bytes" + "database/sql" + "os" + "path/filepath" + "slices" + "testing" + + _ "modernc.org/sqlite" + + "github.com/bashhack/sesh/internal/database" + "github.com/bashhack/sesh/internal/importer/seshexport" + "github.com/bashhack/sesh/internal/password" + "github.com/bashhack/sesh/internal/syncfolder" + "github.com/bashhack/sesh/internal/totp" + "github.com/bashhack/sesh/internal/vault" + "github.com/bashhack/sesh/internal/vaultsync" +) + +// The made-up passwords v0.1.1's files were written with. +const ( + masterPassword = "fixture-v0.1.1-master" + exportPassword = "fixture-v0.1.1-export" +) + +// What v0.1.1's vault was given (make.sh), entry by entry. +var ( + github = vault.Key{Kind: vault.KindPassword, Service: "GitHub", Username: "maya"} + githubTOTP = vault.Key{Kind: vault.KindTOTP, Service: "GitHub", Username: "maya"} + openai = vault.Key{Kind: vault.KindAPIKey, Service: "OpenAI"} + house = vault.Key{Kind: vault.KindNote, Service: "House"} + kube = vault.Key{Kind: vault.KindFile, Service: "kubeconfig"} + shop = vault.Key{Kind: vault.KindPassword, Service: "Shop", Username: "maya@example.org"} + + // Each entry's secret. + secrets = map[vault.Key]string{ + github: "gh-pass-3", + githubTOTP: "JBSWY3DPEHPK3PXP", + openai: "sk-test-0123456789abcdef", + house: "Door code 4711\nAlarm 2580\n", + kube: "apiVersion: v1\nkind: Config\n", + } + // Each entry's settings: the TOTP's code settings decide which codes + // are right. + settings = map[vault.Key]vault.Settings{ + githubTOTP: {TOTP: totp.Params{Issuer: "GitHub", Algorithm: "SHA256", Digits: 8, Period: 60}}, + kube: {FileName: "kubeconfig"}, + } + // GitHub's earlier secrets, the newest first. + githubEarlier = []string{"gh-pass-2", "gh-pass-1"} +) + +// copied is the release's file or folder at name, copied to a temporary +// folder, so a test's writes never reach the one kept here. +func copied(t *testing.T, release, name string) string { //nolint:unparam // a release that changes a format adds files of its own + t.Helper() + src := filepath.Join("testdata", release, name) + dst := filepath.Join(t.TempDir(), name) + err := filepath.WalkDir(src, func(p string, d os.DirEntry, err error) error { + if err != nil { + return err + } + rel, err := filepath.Rel(src, p) + if err != nil { + return err + } + to := filepath.Join(dst, rel) + if d.IsDir() { + return os.MkdirAll(to, 0o700) + } + b, err := os.ReadFile(p) //nolint:gosec // the test's own files + if err != nil { + return err + } + return os.WriteFile(to, b, 0o600) + }) + if err != nil { + t.Fatal(err) + } + return dst +} + +// open opens the vault at path with password, making it when there's none, +// and returns its key too. +func open(t *testing.T, path, pw string) (*database.Store, []byte) { + t.Helper() + src := database.NewMasterPasswordSource(path, func(string) ([]byte, error) { return []byte(pw), nil }) + st, err := database.Open(path, database.NewKeySourceOracle(src)) + if err != nil { + t.Fatalf("open: %v", err) + } + t.Cleanup(func() { + if err := st.Close(); err != nil { + t.Errorf("close: %v", err) + } + }) + key, err := src.GetEncryptionKey() + if err != nil { + t.Fatalf("key: %v", err) + } + return st, key +} + +// checkVault fails for each entry whose secret, settings or filing isn't +// what v0.1.1 kept, and for GitHub's details. +func checkVault(t *testing.T, st *database.Store) { + t.Helper() + for k, want := range secrets { + got, err := st.Get(k) + if err != nil || string(got) != want { + t.Errorf("%v: %q, %v; want %q", k, got, err, want) + } + e, err := st.Lookup(k) + if err != nil { + t.Errorf("%v: %v", k, err) + continue + } + if e.Settings != settings[k] { + t.Errorf("%v's settings: %+v, want %+v", k, e.Settings, settings[k]) + } + if k == github && (e.Folder != "work" || !slices.Equal(e.Tags, []string{"dev"})) { + t.Errorf("GitHub filed in %q, tagged %q", e.Folder, e.Tags) + } + } + d, err := st.Details(github, "compat test") + if err != nil { + t.Fatal(err) + } + checkGitHubDetails(t, &d) +} + +// checkGitHubDetails fails unless d is GitHub's URL, notes and fields. +func checkGitHubDetails(t *testing.T, d *vault.Details) { + t.Helper() + if d.URL != "https://github.com/login" || string(d.Notes) != "Personal account. Recovery codes in the safe.\n" { + t.Errorf("GitHub's URL %q, notes %q", d.URL, d.Notes) + } + fields := map[string]string{} + for _, f := range d.Fields { + fields[f.Name] = string(f.Value) + if f.Secret != (f.Name == "pin") { + t.Errorf("field %s secret=%v", f.Name, f.Secret) + } + } + if len(fields) != 2 || fields["recovery-email"] != "maya@example.com" || fields["pin"] != "4711" { + t.Errorf("GitHub's fields %v", fields) + } +} + +// A vault v0.1.1 made opens, and every entry, its details, its earlier secrets and the trash read back. +func TestVaultV011(t *testing.T) { + st, _ := open(t, copied(t, "v0.1.1", "vault.db"), masterPassword) + checkVault(t, st) + + entries, err := st.List(&vault.Filter{}) + if err != nil { + t.Fatal(err) + } + if len(entries) != len(secrets) { + t.Errorf("%d entries, want the %d v0.1.1 kept", len(entries), len(secrets)) + } + for i, want := range githubEarlier { + got, err := st.ReadVersion(github, i+2, "compat test") // version 1 is the secret now + if err != nil || string(got) != want { + t.Errorf("GitHub's version %d: %q, %v; want %q", i+2, got, err, want) + } + } + trash, err := st.Trash() + if err != nil { + t.Fatal(err) + } + if len(trash) != 1 || trash[0].Key != shop { + t.Errorf("trash %v, want Shop", trash) + } +} + +// schema is the tables, indexes and triggers of the vault at path, as +// SQLite holds their definitions. +func schema(t *testing.T, path string) []string { + t.Helper() + db, err := sql.Open("sqlite", "file:"+path+"?mode=ro") + if err != nil { + t.Fatal(err) + } + defer func() { + if err := db.Close(); err != nil { + t.Error(err) + } + }() + rows, err := db.Query(`SELECT type || ' ' || name || ': ' || COALESCE(sql, '') FROM sqlite_master ORDER BY type, name`) + if err != nil { + t.Fatal(err) + } + var out []string + for rows.Next() { + var s string + if err := rows.Scan(&s); err != nil { + t.Fatal(err) + } + out = append(out, s) + } + if err := rows.Err(); err != nil { + t.Fatal(err) + } + return out +} + +// v0.1.1's vault, opened (and so upgraded), is laid out as a new vault is: a change to an upgrade step already +// released, rather than a new step, leaves the two apart. +func TestVaultSchemaV011(t *testing.T) { + old := copied(t, "v0.1.1", "vault.db") + open(t, old, masterPassword) + fresh := filepath.Join(t.TempDir(), "new.db") + open(t, fresh, "a-new-vault-password") + a, b := schema(t, old), schema(t, fresh) + for _, s := range a { + if !slices.Contains(b, s) { + t.Errorf("only in the upgraded v0.1.1 vault: %s", s) + } + } + for _, s := range b { + if !slices.Contains(a, s) { + t.Errorf("only in a new vault: %s", s) + } + } +} + +// An encrypted export v0.1.1 wrote reads, as sesh import reads it: every entry with its secret, settings, filing and +// details (its exports carry no earlier secrets). +func TestExportV011(t *testing.T) { + data, err := os.ReadFile(filepath.Join("testdata", "v0.1.1", "export.sesh")) + if err != nil { + t.Fatal(err) + } + sealed, err := password.ReadSealed(bytes.NewReader(data)) + if err != nil { + t.Fatal(err) + } + list, err := sealed.Entries([]byte(exportPassword)) + if err != nil { + t.Fatal(err) + } + got := map[vault.Key]bool{} + for _, e := range seshexport.Entries(list) { + got[e.Key] = true + if e.Skip != "" { + t.Errorf("%v skipped: %s", e.Key, e.Skip) + continue + } + if string(e.Secret) != secrets[e.Key] || e.Settings != settings[e.Key] { + t.Errorf("%v: secret %q, settings %+v", e.Key, e.Secret, e.Settings) + } + if e.Key == github { + if e.Folder != "work" || !slices.Equal(e.Tags, []string{"dev"}) { + t.Errorf("GitHub filed in %q, tagged %q", e.Folder, e.Tags) + } + checkGitHubDetails(t, &e.Details) + } + } + for k := range secrets { + if !got[k] { + t.Errorf("%v isn't in the export", k) + } + } +} + +// A sync folder v0.1.1 made reads: every entry's file opens with the vault's key, with its secret, settings and +// earlier secrets, and the trashed entry marked so. +func TestSyncFolderV011(t *testing.T) { + _, key := open(t, copied(t, "v0.1.1", "vault.db"), masterPassword) + f := syncfolder.New(copied(t, "v0.1.1", "sync")) + if _, err := f.ReadInfo(); err != nil { + t.Fatalf("sesh-sync.json: %v", err) + } + c, err := f.Scan() + if err != nil { + t.Fatal(err) + } + if len(c.Problems) != 0 { + t.Errorf("problems: %v", c.Problems) + } + got := map[vault.Key]*syncfolder.Record{} + for i := range c.Files { + b, err := f.Read(&c.Files[i]) + if err != nil { + t.Fatal(err) + } + _, rec, err := syncfolder.Open(database.KeySealer(key), b) + if err != nil { + t.Fatalf("%s: %v", c.Files[i].Path, err) + } + got[vault.Key{Kind: rec.Kind, Service: rec.Service, Username: rec.Username}] = rec + } + for k, want := range secrets { + r := got[k] + if r == nil || string(r.Secret) != want || r.Settings != settings[k] { + t.Errorf("%v: %+v, want secret %q, settings %+v", k, r, want, settings[k]) + } + } + if r := got[github]; r == nil || len(r.History) != len(githubEarlier) || r.URL != "https://github.com/login" { + t.Errorf("GitHub in the folder: %+v", r) + } + if r := got[shop]; r == nil || r.Deleted.IsZero() { + t.Errorf("Shop, in the trash, isn't marked so in the folder: %+v", r) + } +} + +// v0.1.1's vault syncs with its own folder and finds nothing to do: what it kept of the folder still reads. +func TestSyncV011(t *testing.T) { + st, _ := open(t, copied(t, "v0.1.1", "vault.db"), masterPassword) + sum, err := vaultsync.SyncWith(st, syncfolder.New(copied(t, "v0.1.1", "sync")), "test-laptop", vaultsync.Options{}) + if err != nil { + t.Fatal(err) + } + if len(sum.Received)+len(sum.Sent)+len(sum.Merged)+len(sum.Problems)+len(sum.Warnings) != 0 { + t.Errorf("syncing v0.1.1's vault with its own folder did something: %+v", *sum) + } +} + +// Another computer joins v0.1.1's sync folder with its master password, and gets every entry. +func TestJoinV011(t *testing.T) { + f := syncfolder.New(copied(t, "v0.1.1", "sync")) + info, err := f.ReadInfo() + if err != nil { + t.Fatal(err) + } + m := database.UnlockMaterial{Salt: info.Key.Salt, Verify: info.Key.Verify, Params: info.Key.Params} + if err := database.CheckMasterPassword([]byte(masterPassword), m); err != nil { + t.Fatalf("the folder's key record: %v", err) + } + p := filepath.Join(t.TempDir(), "joined.db") + if _, err := database.JoinVault(p, m, info.FolderID, info.Links); err != nil { + t.Fatal(err) + } + st, _ := open(t, p, masterPassword) + if _, err := vaultsync.SyncWith(st, f, "other-laptop", vaultsync.Options{}); err != nil { + t.Fatal(err) + } + checkVault(t, st) +} diff --git a/internal/compat/testdata/v0.1.1/export.sesh b/internal/compat/testdata/v0.1.1/export.sesh new file mode 100644 index 00000000..1736f4c9 --- /dev/null +++ b/internal/compat/testdata/v0.1.1/export.sesh @@ -0,0 +1,12 @@ +{ + "algorithm": "argon2id", + "salt": "eEZmjhpAu8RLsFov6v/MEwzfP5Vhd6X85R4yP+P26/A=", + "ciphertext": "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", + "params": { + "time": 2, + "memory": 19456, + "threads": 1, + "key_len": 32 + }, + "version": 1 +} diff --git a/internal/compat/testdata/v0.1.1/make.sh b/internal/compat/testdata/v0.1.1/make.sh new file mode 100755 index 00000000..d4b4f140 --- /dev/null +++ b/internal/compat/testdata/v0.1.1/make.sh @@ -0,0 +1,84 @@ +#!/bin/bash +# make.sh writes this folder's files with sesh v0.1.1, all made-up: the +# vault (vault.db), an encrypted export (export.sesh) and a sync folder +# (sync/). They're kept as v0.1.1 wrote them; compat_test.go checks that +# today's sesh still reads them. Run it only to remake them, with v0.1.1: +# +# internal/compat/testdata/v0.1.1/make.sh /path/to/sesh-0.1.1 +# +# Nothing personal goes in: a throwaway home for everything, made-up +# passwords, and a made-up computer name (sync records which computer made +# each change, and anyone can open these files with the password below). +set -euo pipefail + +sesh=$1 +out=$(CDPATH='' cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd) +"$sesh" --version | grep -q '^sesh version 0.1.1 ' || { + echo "make.sh: $sesh isn't sesh 0.1.1" >&2 + exit 2 +} + +# Nothing from the caller's own sesh settings. +for v in $(compgen -e | grep '^SESH_' || true); do + unset "$v" +done + +tmp=$(mktemp -d) +trap 'rm -rf "$tmp"' EXIT +mkdir -p "$tmp/home" "$tmp/config" "$tmp/out" +export HOME="$tmp/home" XDG_CONFIG_HOME="$tmp/config" +export SESH_DB_PATH="$tmp/out/vault.db" SESH_AUTH_SOCK="$tmp/agent.sock" +export SESH_MASTER_PASSWORD=fixture-v0.1.1-master SESH_SYNC_NAME=test-laptop +export SESH_KDF_MEMORY=19MiB SESH_KDF_TIME=2 SESH_KDF_THREADS=1 + +printf 'gh-pass-1\n' | "$sesh" add password GitHub --username maya --url https://github.com/login --folder work --tag dev +"$sesh" edit password/GitHub/maya --field recovery-email=maya@example.com +printf '4711\n' | "$sesh" edit password/GitHub/maya --secret-field pin +printf 'Personal account. Recovery codes in the safe.\n' | "$sesh" edit password/GitHub/maya --notes +printf 'gh-pass-2\n' | "$sesh" edit password/GitHub/maya --secret +printf 'gh-pass-3\n' | "$sesh" edit password/GitHub/maya --secret +# A TOTP entry with settings of its own, which decide which codes are right. +printf 'otpauth://totp/GitHub:maya?secret=JBSWY3DPEHPK3PXP&issuer=GitHub&algorithm=SHA256&digits=8&period=60\n' | + "$sesh" add totp GitHub --username maya --folder work +printf 'sk-test-0123456789abcdef\n' | "$sesh" add key OpenAI --folder work --tag dev +printf 'Door code 4711\nAlarm 2580\n' | "$sesh" add note House --folder home +printf 'apiVersion: v1\nkind: Config\n' >"$tmp/kubeconfig" +"$sesh" add file kubeconfig --from "$tmp/kubeconfig" --folder work +printf 'old-shop-pass\n' | "$sesh" add password Shop --username maya@example.org +"$sesh" trash --force password/Shop/maya@example.org + +# The export asks for its password at a terminal: a pseudo-terminal answers. +python3 - "$sesh" export --file "$tmp/out/export.sesh" <<'EOF' +import os, pty, select, signal, sys, time +pid, fd = pty.fork() +if pid == 0: + os.execvp(sys.argv[1], sys.argv[1:]) +out, seen, deadline = b"", 0, time.monotonic() + 60 +while time.monotonic() < deadline: + if not select.select([fd], [], [], 1)[0]: + continue + try: + data = os.read(fd, 4096) + except OSError: + break + if not data: + break + out += data + last = out[seen:].rstrip().lower().split(b"\n")[-1] + if b"password" in last and last.endswith(b":"): + os.write(fd, b"fixture-v0.1.1-export\n") + seen = len(out) +else: + os.kill(pid, signal.SIGKILL) +_, status = os.waitpid(pid, 0) +sys.exit(os.waitstatus_to_exitcode(status)) +EOF + +mkdir "$tmp/sync" +"$sesh" sync init "$tmp/sync"