forked from entailz/toes
New [key-bindings]:
- session-save: captures cwd and foreground process argv to ~/.local/share/foot/state/{name}.json
- session-save-secure: prompts for a password, encrypts the scrollback with argon2id + XChaCha20-Poly1305 (libsodium) and writes it to {name}.scrollback.enc(stores up to 1Mb scrollback buffer).
- session-load: a minimal fuzzy picker that displays saved sessions (both secure and vanilla), UI piggybacks on search bar subsurface. use arrows to navigate and delete to delete a previously saved session.
460 lines
15 KiB
C
460 lines
15 KiB
C
#include "session.h"
|
|
|
|
#include <stdlib.h>
|
|
#include <string.h>
|
|
|
|
#include <sodium.h>
|
|
|
|
#define LOG_MODULE "session-prompt"
|
|
#define LOG_ENABLE_DBG 0
|
|
#include "char32.h"
|
|
#include "extract.h"
|
|
#include "grid.h"
|
|
#include "log.h"
|
|
#include "render.h"
|
|
#include "search.h"
|
|
#include "session-crypto.h"
|
|
#include "terminal.h"
|
|
#include "vt.h"
|
|
#include "xmalloc.h"
|
|
|
|
#define SCROLLBACK_MAX_BYTES (1u << 20) /* 1 MB */
|
|
|
|
static char *
|
|
buf_to_utf8(const struct terminal *term)
|
|
{
|
|
if (term->search.len == 0)
|
|
return NULL;
|
|
/* Make a NUL-terminated char32_t copy for ac32tombs */
|
|
char32_t *tmp = calloc(term->search.len + 1, sizeof(char32_t));
|
|
if (tmp == NULL)
|
|
return NULL;
|
|
memcpy(tmp, term->search.buf, term->search.len * sizeof(char32_t));
|
|
tmp[term->search.len] = U'\0';
|
|
char *utf8 = ac32tombs(tmp);
|
|
free(tmp);
|
|
return utf8;
|
|
}
|
|
|
|
void
|
|
session_picker_init(struct terminal *term)
|
|
{
|
|
session_picker_free(term);
|
|
term->session_picker.all = session_list(&term->session_picker.all_count);
|
|
term->session_picker.filtered_cap = term->session_picker.all_count;
|
|
term->session_picker.filtered = term->session_picker.filtered_cap > 0
|
|
? xmalloc(sizeof(size_t) * term->session_picker.filtered_cap)
|
|
: NULL;
|
|
session_picker_refilter(term);
|
|
}
|
|
|
|
void
|
|
session_picker_free(struct terminal *term)
|
|
{
|
|
session_free_names(term->session_picker.all,
|
|
term->session_picker.all_count);
|
|
free(term->session_picker.filtered);
|
|
term->session_picker.all = NULL;
|
|
term->session_picker.all_count = 0;
|
|
term->session_picker.filtered = NULL;
|
|
term->session_picker.filtered_count = 0;
|
|
term->session_picker.filtered_cap = 0;
|
|
term->session_picker.sel = 0;
|
|
}
|
|
|
|
void
|
|
session_picker_refilter(struct terminal *term)
|
|
{
|
|
char *filter = buf_to_utf8(term);
|
|
term->session_picker.filtered_count = 0;
|
|
for (size_t i = 0; i < term->session_picker.all_count; i++) {
|
|
const char *name = term->session_picker.all[i];
|
|
if (filter == NULL || strstr(name, filter) != NULL)
|
|
term->session_picker.filtered[term->session_picker.filtered_count++] = i;
|
|
}
|
|
free(filter);
|
|
if (term->session_picker.sel >= term->session_picker.filtered_count)
|
|
term->session_picker.sel = term->session_picker.filtered_count > 0
|
|
? term->session_picker.filtered_count - 1
|
|
: 0;
|
|
}
|
|
|
|
void
|
|
session_picker_move(struct terminal *term, int delta)
|
|
{
|
|
if (term->session_picker.filtered_count == 0)
|
|
return;
|
|
long n = (long)term->session_picker.filtered_count;
|
|
long cur = (long)term->session_picker.sel + delta;
|
|
/* Wrap */
|
|
cur = ((cur % n) + n) % n;
|
|
term->session_picker.sel = (size_t)cur;
|
|
}
|
|
|
|
bool
|
|
session_picker_delete_selected(struct terminal *term)
|
|
{
|
|
if (term->session_picker.filtered_count == 0)
|
|
return false;
|
|
size_t idx = term->session_picker.filtered[term->session_picker.sel];
|
|
const char *name = term->session_picker.all[idx];
|
|
if (!session_delete(name))
|
|
return false;
|
|
/* Rebuild the list */
|
|
session_picker_init(term);
|
|
return true;
|
|
}
|
|
|
|
static void
|
|
dismiss(struct terminal *term)
|
|
{
|
|
session_picker_free(term);
|
|
if (term->session_pending_name != NULL)
|
|
sodium_memzero(term->session_pending_name,
|
|
strlen(term->session_pending_name));
|
|
free(term->session_pending_name);
|
|
term->session_pending_name = NULL;
|
|
term->session_pending_secure = false;
|
|
if (term->session_pending_load_state != NULL) {
|
|
struct session_state *st = term->session_pending_load_state;
|
|
session_state_free(st);
|
|
free(st);
|
|
term->session_pending_load_state = NULL;
|
|
}
|
|
/* Zero any residual password bytes in the input buffer */
|
|
if (term->search.buf != NULL && term->search.len > 0)
|
|
sodium_memzero(term->search.buf,
|
|
term->search.len * sizeof(term->search.buf[0]));
|
|
term->search.mode = SEARCH_MODE_NORMAL;
|
|
/* search_cancel clears the search bar; reuse it. We deliberately do not
|
|
* leave selection state behind (no commit semantics for sessions). */
|
|
search_cancel(term);
|
|
}
|
|
|
|
static char *
|
|
extract_scrollback_text(struct terminal *term, size_t *out_len)
|
|
{
|
|
*out_len = 0;
|
|
struct grid *grid = &term->normal;
|
|
if (grid->rows == NULL)
|
|
return NULL;
|
|
|
|
const int total = grid->num_rows;
|
|
const int visible = term->rows;
|
|
const int scrollback = total > visible ? total - visible : 0;
|
|
|
|
struct extraction_context *ctx = extract_begin(SELECTION_NONE, true);
|
|
if (ctx == NULL)
|
|
return NULL;
|
|
for (int row_no = -scrollback; row_no < visible; row_no++) {
|
|
int idx = ((grid->offset + row_no) % total + total) % total;
|
|
const struct row *row = grid->rows[idx];
|
|
if (row == NULL)
|
|
continue;
|
|
for (int col = 0; col < grid->num_cols; col++) {
|
|
if (!extract_one(term, row, &row->cells[col], col, ctx)) {
|
|
char *junk = NULL;
|
|
size_t jl = 0;
|
|
extract_finish(ctx, &junk, &jl);
|
|
free(junk);
|
|
return NULL;
|
|
}
|
|
}
|
|
}
|
|
char *text = NULL;
|
|
size_t len = 0;
|
|
if (!extract_finish(ctx, &text, &len))
|
|
return NULL;
|
|
|
|
/* Trim leading newlines (uninitialized scrollback) */
|
|
size_t start = 0;
|
|
while (start < len && (text[start] == '\n' || text[start] == ' '))
|
|
start++;
|
|
if (start > 0) {
|
|
memmove(text, text + start, len - start + 1);
|
|
len -= start;
|
|
}
|
|
|
|
/* Cap at SCROLLBACK_MAX_BYTES, keeping the newest content. */
|
|
if (len > SCROLLBACK_MAX_BYTES) {
|
|
size_t drop = len - SCROLLBACK_MAX_BYTES;
|
|
const char *nl = memchr(text + drop, '\n', len - drop);
|
|
if (nl != NULL)
|
|
drop = (size_t)(nl + 1 - text);
|
|
memmove(text, text + drop, len - drop + 1);
|
|
len -= drop;
|
|
}
|
|
|
|
*out_len = len;
|
|
return text;
|
|
}
|
|
|
|
|
|
static void
|
|
do_save(struct terminal *term, const char *name)
|
|
{
|
|
struct session_state st = {0};
|
|
if (!session_capture(term->ptmx, term->cwd, &st)) {
|
|
LOG_ERR("session_save: could not capture state");
|
|
session_state_free(&st);
|
|
return;
|
|
}
|
|
if (!session_save(name, &st))
|
|
LOG_ERR("session_save: write failed for '%s'", name);
|
|
session_state_free(&st);
|
|
|
|
/* Plain save deliberately drops any stale encrypted scrollback so the
|
|
* session reflects the current save. */
|
|
session_delete_enc_scrollback(name);
|
|
session_delete_scrollback(name);
|
|
}
|
|
|
|
static void
|
|
do_save_secure(struct terminal *term, const char *name, const char *password)
|
|
{
|
|
struct session_state st = {0};
|
|
if (!session_capture(term->ptmx, term->cwd, &st)) {
|
|
LOG_ERR("session_save_secure: could not capture state");
|
|
session_state_free(&st);
|
|
return;
|
|
}
|
|
bool ok = session_save(name, &st);
|
|
session_state_free(&st);
|
|
if (!ok) {
|
|
LOG_ERR("session_save_secure: JSON write failed for '%s'", name);
|
|
return;
|
|
}
|
|
/* Capture scrollback as plain text, encrypt, write sidecar. */
|
|
size_t plain_len = 0;
|
|
char *plain = extract_scrollback_text(term, &plain_len);
|
|
if (plain == NULL || plain_len == 0) {
|
|
/* No scrollback to encrypt — clear any stale ciphertext. */
|
|
free(plain);
|
|
session_delete_enc_scrollback(name);
|
|
session_delete_scrollback(name);
|
|
return;
|
|
}
|
|
unsigned char *blob = NULL;
|
|
size_t blob_len = 0;
|
|
bool enc_ok = session_crypto_encrypt(
|
|
password, (const unsigned char *)plain, plain_len, &blob, &blob_len);
|
|
sodium_memzero(plain, plain_len);
|
|
free(plain);
|
|
if (!enc_ok) {
|
|
LOG_ERR("session_save_secure: encryption failed");
|
|
return;
|
|
}
|
|
if (!session_write_enc_blob(name, blob, blob_len))
|
|
LOG_ERR("session_save_secure: sidecar write failed");
|
|
/* Remove any legacy plain sidecar so we never load it accidentally. */
|
|
session_delete_scrollback(name);
|
|
sodium_memzero(blob, blob_len);
|
|
free(blob);
|
|
}
|
|
|
|
/*
|
|
* Replay a saved scrollback into a freshly created terminal. Walks the bytes
|
|
* once to convert bare LF into CRLF (so each saved line lands at column 0),
|
|
* then runs them through vt_from_slave. Called from the LOAD path right after
|
|
* the new tab is spawned, before any output from the new slave has arrived.
|
|
*/
|
|
static void
|
|
replay_scrollback(struct terminal *new_term, const char *text, size_t len)
|
|
{
|
|
if (text == NULL || len == 0 || new_term == NULL)
|
|
return;
|
|
|
|
char *buf = xmalloc(len * 2);
|
|
size_t out = 0;
|
|
for (size_t i = 0; i < len; i++) {
|
|
char c = text[i];
|
|
if (c == '\n')
|
|
buf[out++] = '\r';
|
|
buf[out++] = c;
|
|
}
|
|
if (out > 0)
|
|
buf[out++] = '\n';
|
|
|
|
/* Visually separate replayed content from the upcoming shell prompt */
|
|
/* (single trailing newline is enough; the shell will emit its own CRLF) */
|
|
|
|
vt_from_slave(new_term, (const uint8_t *)buf, out);
|
|
free(buf);
|
|
}
|
|
|
|
void
|
|
session_prompt_confirm_overwrite(struct terminal *term)
|
|
{
|
|
if (term->session_pending_name == NULL) {
|
|
dismiss(term);
|
|
return;
|
|
}
|
|
if (term->session_pending_secure) {
|
|
/* Don't save yet — transition to password prompt. */
|
|
term->search.mode = SEARCH_MODE_SESSION_SAVE_SECURE_PASSWORD;
|
|
term->search.len = 0;
|
|
term->search.cursor = 0;
|
|
if (term->search.buf != NULL)
|
|
term->search.buf[0] = U'\0';
|
|
render_refresh_search(term);
|
|
return;
|
|
}
|
|
do_save(term, term->session_pending_name);
|
|
dismiss(term);
|
|
}
|
|
|
|
void
|
|
session_prompt_cancel(struct terminal *term)
|
|
{
|
|
dismiss(term);
|
|
}
|
|
|
|
static void
|
|
spawn_and_replay(struct terminal *term, struct session_state *st,
|
|
const char *name, const char *password)
|
|
{
|
|
struct terminal *nt = term_tab_new_with_cwd(
|
|
term, st->cwd, st->argc, st->argv, NULL,
|
|
term->shutdown.cb, term->shutdown.cb_data);
|
|
if (nt == NULL) {
|
|
LOG_ERR("session_load: could not spawn new tab");
|
|
return;
|
|
}
|
|
/* Encrypted sidecar takes priority if present. */
|
|
if (password != NULL && session_has_enc_scrollback(name)) {
|
|
unsigned char *blob = NULL;
|
|
size_t blob_len = 0;
|
|
if (session_read_enc_blob(name, &blob, &blob_len)) {
|
|
unsigned char *plain = NULL;
|
|
size_t plain_len = 0;
|
|
if (session_crypto_decrypt(password, blob, blob_len,
|
|
&plain, &plain_len)) {
|
|
replay_scrollback(nt, (const char *)plain, plain_len);
|
|
sodium_memzero(plain, plain_len);
|
|
free(plain);
|
|
} else {
|
|
LOG_ERR("session_load: decryption failed for '%s'", name);
|
|
}
|
|
free(blob);
|
|
}
|
|
} else {
|
|
/* Plain sidecar (legacy or never-encrypted). */
|
|
char *sb_text = NULL;
|
|
size_t sb_len = 0;
|
|
if (session_load_scrollback(name, &sb_text, &sb_len))
|
|
replay_scrollback(nt, sb_text, sb_len);
|
|
free(sb_text);
|
|
}
|
|
}
|
|
|
|
void
|
|
session_prompt_commit(struct terminal *term)
|
|
{
|
|
enum search_mode mode = term->search.mode;
|
|
|
|
if (mode == SEARCH_MODE_SESSION_SAVE ||
|
|
mode == SEARCH_MODE_SESSION_SAVE_SECURE_NAME) {
|
|
const bool secure = (mode == SEARCH_MODE_SESSION_SAVE_SECURE_NAME);
|
|
char *name = buf_to_utf8(term);
|
|
if (name == NULL || !session_name_is_valid(name)) {
|
|
LOG_ERR("session name must be non-empty [A-Za-z0-9._-]");
|
|
free(name);
|
|
dismiss(term);
|
|
return;
|
|
}
|
|
if (session_exists(name)) {
|
|
free(term->session_pending_name);
|
|
term->session_pending_name = name;
|
|
term->session_pending_secure = secure;
|
|
term->search.mode = SEARCH_MODE_SESSION_OVERWRITE_CONFIRM;
|
|
render_refresh_search(term);
|
|
return;
|
|
}
|
|
if (secure) {
|
|
/* No overwrite needed, go straight to password prompt. */
|
|
free(term->session_pending_name);
|
|
term->session_pending_name = name;
|
|
term->session_pending_secure = true;
|
|
term->search.mode = SEARCH_MODE_SESSION_SAVE_SECURE_PASSWORD;
|
|
term->search.len = 0;
|
|
term->search.cursor = 0;
|
|
if (term->search.buf != NULL)
|
|
term->search.buf[0] = U'\0';
|
|
render_refresh_search(term);
|
|
return;
|
|
}
|
|
do_save(term, name);
|
|
free(name);
|
|
} else if (mode == SEARCH_MODE_SESSION_SAVE_SECURE_PASSWORD) {
|
|
char *password = buf_to_utf8(term);
|
|
if (password == NULL || password[0] == '\0') {
|
|
LOG_ERR("session_save_secure: empty password");
|
|
free(password);
|
|
dismiss(term);
|
|
return;
|
|
}
|
|
const char *name = term->session_pending_name;
|
|
if (name == NULL) {
|
|
sodium_memzero(password, strlen(password));
|
|
free(password);
|
|
dismiss(term);
|
|
return;
|
|
}
|
|
do_save_secure(term, name, password);
|
|
sodium_memzero(password, strlen(password));
|
|
free(password);
|
|
} else if (mode == SEARCH_MODE_SESSION_LOAD) {
|
|
if (term->session_picker.filtered_count == 0) {
|
|
LOG_ERR("session_load: no matching session");
|
|
dismiss(term);
|
|
return;
|
|
}
|
|
size_t idx = term->session_picker.filtered[term->session_picker.sel];
|
|
const char *picked = term->session_picker.all[idx];
|
|
|
|
struct session_state *st = xmalloc(sizeof(*st));
|
|
memset(st, 0, sizeof(*st));
|
|
if (!session_load(picked, st)) {
|
|
LOG_ERR("session_load: could not read '%s'", picked);
|
|
free(st);
|
|
dismiss(term);
|
|
return;
|
|
}
|
|
if (session_has_enc_scrollback(picked)) {
|
|
/* Stash state, transition to password mode. */
|
|
free(term->session_pending_name);
|
|
term->session_pending_name = xstrdup(picked);
|
|
term->session_pending_load_state = st;
|
|
term->search.mode = SEARCH_MODE_SESSION_LOAD_PASSWORD;
|
|
term->search.len = 0;
|
|
term->search.cursor = 0;
|
|
if (term->search.buf != NULL)
|
|
term->search.buf[0] = U'\0';
|
|
session_picker_free(term);
|
|
render_refresh_search(term);
|
|
return;
|
|
}
|
|
/* No encrypted scrollback — spawn now. */
|
|
spawn_and_replay(term, st, picked, NULL);
|
|
session_state_free(st);
|
|
free(st);
|
|
} else if (mode == SEARCH_MODE_SESSION_LOAD_PASSWORD) {
|
|
char *password = buf_to_utf8(term);
|
|
struct session_state *st = term->session_pending_load_state;
|
|
const char *name = term->session_pending_name;
|
|
if (st != NULL && name != NULL && password != NULL) {
|
|
spawn_and_replay(term, st, name, password);
|
|
}
|
|
if (st != NULL) {
|
|
session_state_free(st);
|
|
free(st);
|
|
term->session_pending_load_state = NULL;
|
|
}
|
|
if (password != NULL) {
|
|
sodium_memzero(password, strlen(password));
|
|
free(password);
|
|
}
|
|
}
|
|
|
|
dismiss(term);
|
|
}
|