#include "session.h" #include #include #include #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); }