diff --git a/config.c b/config.c index 2a552a6..7ea297e 100644 --- a/config.c +++ b/config.c @@ -130,6 +130,11 @@ static const char *const binding_action_map[] = { [BIND_ACTION_TAB_9] = "tab-9", [BIND_ACTION_TAB_OVERVIEW] = "tab-overview", + /* Session actions */ + [BIND_ACTION_SESSION_SAVE] = "session-save", + [BIND_ACTION_SESSION_LOAD] = "session-load", + [BIND_ACTION_SESSION_SAVE_SECURE] = "session-save-secure", + /* Mouse-specific actions */ [BIND_ACTION_SCROLLBACK_UP_MOUSE] = "scrollback-up-mouse", [BIND_ACTION_SCROLLBACK_DOWN_MOUSE] = "scrollback-down-mouse", diff --git a/csi.c b/csi.c index 9bdda99..f4f16fd 100644 --- a/csi.c +++ b/csi.c @@ -1583,7 +1583,10 @@ void csi_dispatch(struct terminal *term, uint8_t final) { /* 0 - icon + title, 1 - icon, 2 - title */ unsigned what = vt_param_get(term, 1, 0); if (what == 0 || what == 2) { - tll_push_back(term->window_title_stack, xstrdup(term->window_title)); + tll_push_back(term->window_title_stack, + xstrdup(term->window_title != NULL + ? term->window_title + : "")); } break; } diff --git a/flake.lock b/flake.lock new file mode 100644 index 0000000..c98e08e --- /dev/null +++ b/flake.lock @@ -0,0 +1,27 @@ +{ + "nodes": { + "nixpkgs": { + "locked": { + "lastModified": 1779259093, + "narHash": "sha256-7DKWmH23hL2eYdkxCKeqj2i+yljTKuU+3Nk1UPHOnxc=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "d99b013d5d1931ad77fe3912ed218170dec5d9a4", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixpkgs-unstable", + "repo": "nixpkgs", + "type": "github" + } + }, + "root": { + "inputs": { + "nixpkgs": "nixpkgs" + } + } + }, + "root": "root", + "version": 7 +} diff --git a/flake.nix b/flake.nix new file mode 100644 index 0000000..abcd22c --- /dev/null +++ b/flake.nix @@ -0,0 +1,39 @@ +{ + description = "toes terminal emulator"; + + inputs = { + nixpkgs.url = "github:NixOS/nixpkgs/nixpkgs-unstable"; + }; + + outputs = + { nixpkgs, ... }: + let + systems = [ + "aarch64-linux" + "x86_64-linux" + ]; + + forAllSystems = nixpkgs.lib.genAttrs systems; + in + { + packages = forAllSystems ( + system: + let + pkgs = import nixpkgs { inherit system; }; + + toes = pkgs.foot.overrideAttrs (prev: { + pname = "toes"; + version = "1.26.1"; + src = ./.; + buildInputs = (prev.buildInputs or [ ]) ++ [ + pkgs.libsodium + ]; + }); + in + { + inherit toes; + default = toes; + } + ); + }; +} diff --git a/foot.ini b/foot.ini index 3d4183d..3381f20 100644 --- a/foot.ini +++ b/foot.ini @@ -251,6 +251,9 @@ height=26 # tab-next=Control+Tab # tab-prev=Control+Shift+Tab # tab-overview=Control+Shift+space +# session-save=Control+Shift+s +# session-load=Control+Shift+l +# session-save-secure=Control+Shift+Alt+s [search-bindings] # cancel=Control+g Control+c Escape diff --git a/input.c b/input.c index 969952a..fb02113 100644 --- a/input.c +++ b/input.c @@ -491,6 +491,18 @@ static bool execute_binding(struct seat *seat, struct terminal *term, tab_overview_toggle(term->window); return true; + case BIND_ACTION_SESSION_SAVE: + search_begin_session(term, SEARCH_MODE_SESSION_SAVE); + return true; + + case BIND_ACTION_SESSION_LOAD: + search_begin_session(term, SEARCH_MODE_SESSION_LOAD); + return true; + + case BIND_ACTION_SESSION_SAVE_SECURE: + search_begin_session(term, SEARCH_MODE_SESSION_SAVE_SECURE_NAME); + return true; + case BIND_ACTION_REGEX_LAUNCH: case BIND_ACTION_REGEX_COPY: if (binding->aux->type != BINDING_AUX_REGEX) diff --git a/key-binding.h b/key-binding.h index 45702ee..e008ecc 100644 --- a/key-binding.h +++ b/key-binding.h @@ -65,6 +65,11 @@ enum bind_action_normal { BIND_ACTION_TAB_9, BIND_ACTION_TAB_OVERVIEW, + /* Session actions */ + BIND_ACTION_SESSION_SAVE, + BIND_ACTION_SESSION_LOAD, + BIND_ACTION_SESSION_SAVE_SECURE, + /* Mouse specific actions - i.e. they require a mouse coordinate */ BIND_ACTION_SCROLLBACK_UP_MOUSE, BIND_ACTION_SCROLLBACK_DOWN_MOUSE, @@ -77,7 +82,7 @@ enum bind_action_normal { BIND_ACTION_SELECT_QUOTE, BIND_ACTION_SELECT_ROW, - BIND_ACTION_KEY_COUNT = BIND_ACTION_TAB_OVERVIEW + 1, + BIND_ACTION_KEY_COUNT = BIND_ACTION_SESSION_SAVE_SECURE + 1, BIND_ACTION_COUNT = BIND_ACTION_SELECT_ROW + 1, }; diff --git a/meson.build b/meson.build index a0e602b..9a1efa1 100644 --- a/meson.build +++ b/meson.build @@ -162,6 +162,7 @@ endif tllist = dependency('tllist', version: '>=1.1.0', fallback: 'tllist') fcft = dependency('fcft', version: ['>=3.3.1', '<4.0.0'], fallback: 'fcft') +libsodium = dependency('libsodium') wayland_protocols_datadir = wayland_protocols.get_variable('pkgdatadir') @@ -330,6 +331,8 @@ executable( 'reaper.c', 'reaper.h', 'render.c', 'render.h', 'search.c', 'search.h', + 'session.c', 'session.h', 'session-prompt.c', + 'session-crypto.c', 'session-crypto.h', 'server.c', 'server.h', 'client-protocol.h', 'shm.c', 'shm.h', 'slave.c', 'slave.h', @@ -342,7 +345,7 @@ executable( 'xkbcommon-vmod.h', srgb_funcs, wl_proto_src + wl_proto_headers, version, dependencies: [math, threads, libepoll, pixman, wayland_client, wayland_cursor, xkb, fontconfig, utf8proc, - tllist, fcft], + tllist, fcft, libsodium], link_with: pgolib, install: true) diff --git a/render.c b/render.c index 2b8971a..2e2d16b 100644 --- a/render.c +++ b/render.c @@ -3635,6 +3635,21 @@ static void render_search_box(struct terminal *term) { const size_t text_len = term->search.len; #endif + /* Password modes: replace every codepoint with a mask glyph for display. */ + char32_t *masked_text = NULL; + char32_t *ime_alloc = NULL; +#if defined(FOOT_IME_ENABLED) && FOOT_IME_ENABLED + ime_alloc = text; /* remember the IME-allocated buffer for cleanup */ +#endif + if (term->search.mode == SEARCH_MODE_SESSION_SAVE_SECURE_PASSWORD || + term->search.mode == SEARCH_MODE_SESSION_LOAD_PASSWORD) { + masked_text = xmalloc((text_len + 1) * sizeof(char32_t)); + for (size_t i = 0; i < text_len; i++) + masked_text[i] = U'•'; + masked_text[text_len] = U'\0'; + text = masked_text; + } + /* Calculate the width of each character */ int widths[text_len + 1]; for (size_t i = 0; i < text_len; i++) @@ -3642,7 +3657,16 @@ static void render_search_box(struct terminal *term) { widths[text_len] = 0; const size_t total_cells = c32swidth(text, text_len); - const size_t wanted_visible_cells = max(20, total_cells); + size_t wanted_visible_cells = max(20, total_cells); + if (term->search.mode == SEARCH_MODE_SESSION_LOAD) { + /* Make room for the longest session name */ + for (size_t i = 0; i < term->session_picker.all_count; i++) { + size_t n = strlen(term->session_picker.all[i]); + if (n > wanted_visible_cells) + wanted_visible_cells = n; + } + wanted_visible_cells = max(wanted_visible_cells, 30); + } /* * Build status string: " Aa Wb Re 3/17 ↻" @@ -3655,36 +3679,70 @@ static void render_search_box(struct terminal *term) { char32_t status[64]; size_t st_len = 0; - if (term->search.case_mode == SEARCH_CASE_SENSITIVE) { - status[st_len++] = U'A'; - status[st_len++] = U'a'; - status[st_len++] = U' '; - } else if (term->search.case_mode == SEARCH_CASE_INSENSITIVE) { - status[st_len++] = U'a'; - status[st_len++] = U'a'; - status[st_len++] = U' '; - } - if (term->search.whole_word) { - status[st_len++] = U'W'; - status[st_len++] = U'b'; - status[st_len++] = U' '; - } - if (term->search.regex) { - status[st_len++] = U'.'; - status[st_len++] = U'*'; - status[st_len++] = U' '; - } - if (term->search.wrapped) { - status[st_len++] = U'~'; - status[st_len++] = U' '; - } - if (term->search.len > 0 && term->search.total_count > 0) { - char tmp[32]; - snprintf(tmp, sizeof(tmp), "%zu/%zu", term->search.current_idx, - term->search.total_count); - for (size_t i = 0; tmp[i] != '\0' && st_len < ALEN(status) - 1; i++) - status[st_len++] = (char32_t)tmp[i]; - status[st_len++] = U' '; + if (term->search.mode != SEARCH_MODE_NORMAL) { + char label_buf[128]; + const char *label; + switch (term->search.mode) { + case SEARCH_MODE_SESSION_OVERWRITE_CONFIRM: + snprintf(label_buf, sizeof(label_buf), "Overwrite '%s'? (y/n) ", + term->session_pending_name != NULL + ? term->session_pending_name + : ""); + label = label_buf; + break; + case SEARCH_MODE_SESSION_SAVE: + label = "Save State As: "; + break; + case SEARCH_MODE_SESSION_SAVE_SECURE_NAME: + label = "Secure Save As: "; + break; + case SEARCH_MODE_SESSION_SAVE_SECURE_PASSWORD: + label = "Password (new): "; + break; + case SEARCH_MODE_SESSION_LOAD: + label = "Load State: "; + break; + case SEARCH_MODE_SESSION_LOAD_PASSWORD: + label = "Decrypt Password: "; + break; + default: + label = ""; + break; + } + for (const char *p = label; *p != '\0' && st_len < ALEN(status) - 1; p++) + status[st_len++] = (char32_t)(unsigned char)*p; + } else { + if (term->search.case_mode == SEARCH_CASE_SENSITIVE) { + status[st_len++] = U'A'; + status[st_len++] = U'a'; + status[st_len++] = U' '; + } else if (term->search.case_mode == SEARCH_CASE_INSENSITIVE) { + status[st_len++] = U'a'; + status[st_len++] = U'a'; + status[st_len++] = U' '; + } + if (term->search.whole_word) { + status[st_len++] = U'W'; + status[st_len++] = U'b'; + status[st_len++] = U' '; + } + if (term->search.regex) { + status[st_len++] = U'.'; + status[st_len++] = U'*'; + status[st_len++] = U' '; + } + if (term->search.wrapped) { + status[st_len++] = U'~'; + status[st_len++] = U' '; + } + if (term->search.len > 0 && term->search.total_count > 0) { + char tmp[32]; + snprintf(tmp, sizeof(tmp), "%zu/%zu", term->search.current_idx, + term->search.total_count); + for (size_t i = 0; tmp[i] != '\0' && st_len < ALEN(status) - 1; i++) + status[st_len++] = (char32_t)tmp[i]; + status[st_len++] = U' '; + } } status[st_len] = U'\0'; const size_t status_cells = c32swidth(status, st_len); @@ -3704,8 +3762,20 @@ static void render_search_box(struct terminal *term) { const size_t want_box_w = chrome_w + wanted_visible_cells * term->cell_width; size_t width = min(want_box_w, max_box_w); - size_t height = - min(term->height - 2 * outer_margin, margin + term->cell_height + margin); + + /* Session-load picker: grow box to fit a few rows of session names */ + size_t list_rows = 0; + if (term->search.mode == SEARCH_MODE_SESSION_LOAD) { + const size_t max_visible = 10; + list_rows = min(max_visible, term->session_picker.filtered_count); + /* Reserve at least one row so empty state is visible */ + if (list_rows == 0) + list_rows = 1; + } + + size_t height = min( + term->height - 2 * outer_margin, + margin + (1 + list_rows) * term->cell_height + margin); width = roundf(scale * ceilf(width / scale)); height = roundf(scale * ceilf(height / scale)); @@ -3732,12 +3802,14 @@ static void render_search_box(struct terminal *term) { uint32_t bg_hex = term->colors.bg; uint32_t fg_hex = term->colors.fg; - if (term->search.len > 0 && term->search.match_len == 0) { - bg_hex = term->colors.table[1]; - fg_hex = term->colors.table[0]; - } else if (term->search.wrapped) { - bg_hex = term->colors.table[3]; - fg_hex = term->colors.table[0]; + if (term->search.mode == SEARCH_MODE_NORMAL) { + if (term->search.len > 0 && term->search.match_len == 0) { + bg_hex = term->colors.table[1]; + fg_hex = term->colors.table[0]; + } else if (term->search.wrapped) { + bg_hex = term->colors.table[3]; + fg_hex = term->colors.table[0]; + } } const pixman_color_t color = color_hex_to_pixman(bg_hex, gamma_correct); @@ -3973,6 +4045,80 @@ static void render_search_box(struct terminal *term) { } } + /* Session-load list */ + if (term->search.mode == SEARCH_MODE_SESSION_LOAD) { + const pixman_color_t sel_bg = color_hex_to_pixman( + term->colors.table[4], gamma_correct); + const size_t name_x = margin; + const size_t name_w = width > 2 * margin ? width - 2 * margin : 0; + + if (term->session_picker.filtered_count == 0) { + const char32_t *msg = U"(no sessions)"; + int rx = (int)name_x; + const int ry = (int)margin + (int)term->cell_height; + for (size_t i = 0; msg[i] != U'\0'; i++) { + const struct fcft_glyph *g = + fcft_rasterize_char_utf32(font, msg[i], term->font_subpixel); + int gw = max(1, c32width(msg[i])); + if (g != NULL && !g->is_color_glyph) { + pixman_image_t *src = pixman_image_create_solid_fill(&fg); + pixman_image_composite32(PIXMAN_OP_OVER, src, g->pix, buf->pix[0], 0, + 0, 0, 0, rx + x_ofs + g->x, + ry + term->font_baseline - g->y, g->width, + g->height); + pixman_image_unref(src); + } + rx += gw * (int)term->cell_width; + } + } else { + const size_t shown = min(list_rows, term->session_picker.filtered_count); + /* Scroll the list so the selection is visible */ + size_t first = 0; + if (term->session_picker.sel >= shown) + first = term->session_picker.sel - shown + 1; + for (size_t r = 0; r < shown; r++) { + size_t fi = first + r; + if (fi >= term->session_picker.filtered_count) + break; + const char *name = + term->session_picker.all[term->session_picker.filtered[fi]]; + + const int ry = (int)margin + (int)(1 + r) * (int)term->cell_height; + const bool selected = fi == term->session_picker.sel; + + if (selected) { + pixman_image_fill_rectangles( + PIXMAN_OP_SRC, buf->pix[0], &sel_bg, 1, + &(pixman_rectangle16_t){(int16_t)name_x, (int16_t)ry, + (uint16_t)name_w, + (uint16_t)term->cell_height}); + } + + const pixman_color_t row_fg = selected + ? color_hex_to_pixman(term->colors.bg, gamma_correct) + : fg; + + int rx = (int)name_x; + for (const char *p = name; *p != '\0'; p++) { + char32_t ch = (char32_t)(unsigned char)*p; + const struct fcft_glyph *g = + fcft_rasterize_char_utf32(font, ch, term->font_subpixel); + if (g != NULL && !g->is_color_glyph) { + pixman_image_t *src = pixman_image_create_solid_fill(&row_fg); + pixman_image_composite32(PIXMAN_OP_OVER, src, g->pix, buf->pix[0], + 0, 0, 0, 0, rx + x_ofs + g->x, + ry + term->font_baseline - g->y, + g->width, g->height); + pixman_image_unref(src); + } + rx += (int)term->cell_width; + if (rx > (int)(name_x + name_w)) + break; + } + } + } + } + quirk_weston_subsurface_desync_on(term->window->search.sub); /* TODO: this is only necessary on a window resize */ @@ -3998,9 +4144,8 @@ static void render_search_box(struct terminal *term) { wl_surface_commit(term->window->search.surface.surf); quirk_weston_subsurface_desync_off(term->window->search.sub); -#if defined(FOOT_IME_ENABLED) && FOOT_IME_ENABLED - free(text); -#endif + free(ime_alloc); + free(masked_text); #undef WINDOW_X #undef WINDOW_Y } diff --git a/search.c b/search.c index e50d964..3d6d632 100644 --- a/search.c +++ b/search.c @@ -20,6 +20,7 @@ #include "quirks.h" #include "render.h" #include "selection.h" +#include "session.h" #include "shm.h" #include "unicode-mode.h" #include "util.h" @@ -155,6 +156,9 @@ static void search_cancel_keep_selection(struct terminal *term) { term->search.wrapped = false; term->search.history_pos = NULL; term->is_searching = false; + if (term->search.mode == SEARCH_MODE_SESSION_LOAD) + session_picker_free(term); + term->search.mode = SEARCH_MODE_NORMAL; term->render.search_glyph_offset = 0; /* Reset IME state */ @@ -193,11 +197,20 @@ void search_begin(struct terminal *term) { term->search.sz = 64; term->search.buf = xmalloc(term->search.sz * sizeof(term->search.buf[0])); term->search.buf[0] = U'\0'; + term->search.mode = SEARCH_MODE_NORMAL; term_xcursor_update(term); render_refresh_search(term); } +void search_begin_session(struct terminal *term, enum search_mode mode) { + search_begin(term); + term->search.mode = mode; + if (mode == SEARCH_MODE_SESSION_LOAD) + session_picker_init(term); + render_refresh_search(term); +} + void search_cancel(struct terminal *term) { if (!term->is_searching) return; @@ -1539,6 +1552,10 @@ static bool execute_binding(struct seat *seat, struct terminal *term, return true; case BIND_ACTION_SEARCH_COMMIT: + if (term->search.mode != SEARCH_MODE_NORMAL) { + session_prompt_commit(term); + return true; + } selection_finalize(seat, term, serial); search_cancel_keep_selection(term); return true; @@ -1881,6 +1898,48 @@ void search_input(struct seat *seat, struct terminal *term, LOG_DBG("search: input: sym=%d/0x%x, mods=0x%08x, consumed=0x%08x", sym, sym, mods, consumed); + /* Overwrite-confirm: only y/Y proceeds; anything else cancels. */ + if (term->search.mode == SEARCH_MODE_SESSION_OVERWRITE_CONFIRM) { + if (sym == XKB_KEY_y || sym == XKB_KEY_Y || sym == XKB_KEY_Return || + sym == XKB_KEY_KP_Enter) { + session_prompt_confirm_overwrite(term); + } else { + session_prompt_cancel(term); + } + return; + } + + /* In session-load mode, hijack a few keys for picker navigation. We do this + * before binding dispatch so configured search-bindings (e.g. history-prev + * on Up) don't fire. */ + if (term->search.mode == SEARCH_MODE_SESSION_LOAD) { + bool handled = true; + switch (sym) { + case XKB_KEY_Up: + session_picker_move(term, -1); + break; + case XKB_KEY_Down: + session_picker_move(term, +1); + break; + case XKB_KEY_Page_Up: + session_picker_move(term, -5); + break; + case XKB_KEY_Page_Down: + session_picker_move(term, +5); + break; + case XKB_KEY_Delete: + session_picker_delete_selected(term); + break; + default: + handled = false; + break; + } + if (handled) { + render_refresh_search(term); + return; + } + } + enum xkb_compose_status compose_status = seat->kbd.xkb_compose_state != NULL ? xkb_compose_state_get_status(seat->kbd.xkb_compose_state) @@ -1969,8 +2028,10 @@ void search_input(struct seat *seat, struct terminal *term, update_search: LOG_DBG("search: buffer: %ls", (const wchar_t *)term->search.buf); - if (update_search_result) + if (update_search_result && term->search.mode == SEARCH_MODE_NORMAL) search_find_next(term, search_direction); + if (term->search.mode == SEARCH_MODE_SESSION_LOAD) + session_picker_refilter(term); if (redraw) render_refresh_search(term); } diff --git a/search.h b/search.h index ee8ecd7..c8fba13 100644 --- a/search.h +++ b/search.h @@ -6,6 +6,7 @@ #include "terminal.h" void search_begin(struct terminal *term); +void search_begin_session(struct terminal *term, enum search_mode mode); void search_cancel(struct terminal *term); void search_input( struct seat *seat, struct terminal *term, diff --git a/session-crypto.c b/session-crypto.c new file mode 100644 index 0000000..f2af313 --- /dev/null +++ b/session-crypto.c @@ -0,0 +1,160 @@ +#include "session-crypto.h" + +#include +#include + +#include + +#define LOG_MODULE "session-crypto" +#define LOG_ENABLE_DBG 0 +#include "log.h" +#include "xmalloc.h" + +#define MAGIC "FOOT-ENC1\0" +#define MAGIC_LEN 10 + +static bool sodium_ready = false; + +bool +session_crypto_init(void) +{ + if (sodium_ready) + return true; + if (sodium_init() < 0) { + LOG_ERR("libsodium init failed"); + return false; + } + sodium_ready = true; + return true; +} + +static bool +derive_key(const char *password, const unsigned char *salt, + unsigned char key[crypto_aead_xchacha20poly1305_ietf_KEYBYTES]) +{ + return crypto_pwhash( + key, crypto_aead_xchacha20poly1305_ietf_KEYBYTES, + password, strlen(password), salt, + crypto_pwhash_OPSLIMIT_INTERACTIVE, + crypto_pwhash_MEMLIMIT_INTERACTIVE, + crypto_pwhash_ALG_ARGON2ID13) == 0; +} + +bool +session_crypto_encrypt(const char *password, + const unsigned char *plaintext, size_t plaintext_len, + unsigned char **out, size_t *out_len) +{ + if (!session_crypto_init()) + return false; + *out = NULL; + *out_len = 0; + + const size_t salt_len = crypto_pwhash_SALTBYTES; + const size_t nonce_len = crypto_aead_xchacha20poly1305_ietf_NPUBBYTES; + const size_t tag_len = crypto_aead_xchacha20poly1305_ietf_ABYTES; + + const size_t total = MAGIC_LEN + salt_len + nonce_len + plaintext_len + tag_len; + unsigned char *buf = xmalloc(total); + memcpy(buf, MAGIC, MAGIC_LEN); + + unsigned char *salt = buf + MAGIC_LEN; + unsigned char *nonce = salt + salt_len; + unsigned char *cipher = nonce + nonce_len; + + randombytes_buf(salt, salt_len); + randombytes_buf(nonce, nonce_len); + + unsigned char key[crypto_aead_xchacha20poly1305_ietf_KEYBYTES]; + if (!derive_key(password, salt, key)) { + LOG_ERR("crypto_pwhash failed (out of memory?)"); + sodium_memzero(key, sizeof(key)); + free(buf); + return false; + } + + /* Bind the file header into the AEAD so any tampering breaks decryption. */ + unsigned long long clen = 0; + int rc = crypto_aead_xchacha20poly1305_ietf_encrypt( + cipher, &clen, + plaintext, plaintext_len, + buf, MAGIC_LEN + salt_len + nonce_len, /* AAD = full header */ + NULL, + nonce, + key); + sodium_memzero(key, sizeof(key)); + + if (rc != 0) { + LOG_ERR("xchacha20poly1305 encrypt failed"); + free(buf); + return false; + } + /* Sanity */ + if (clen != plaintext_len + tag_len) { + free(buf); + return false; + } + + *out = buf; + *out_len = total; + return true; +} + +bool +session_crypto_decrypt(const char *password, + const unsigned char *blob, size_t blob_len, + unsigned char **out, size_t *out_len) +{ + if (!session_crypto_init()) + return false; + *out = NULL; + *out_len = 0; + + const size_t salt_len = crypto_pwhash_SALTBYTES; + const size_t nonce_len = crypto_aead_xchacha20poly1305_ietf_NPUBBYTES; + const size_t tag_len = crypto_aead_xchacha20poly1305_ietf_ABYTES; + const size_t header_len = MAGIC_LEN + salt_len + nonce_len; + + if (blob_len < header_len + tag_len) { + LOG_ERR("encrypted blob too small (%zu bytes)", blob_len); + return false; + } + if (memcmp(blob, MAGIC, MAGIC_LEN) != 0) { + LOG_ERR("bad magic in encrypted scrollback"); + return false; + } + + const unsigned char *salt = blob + MAGIC_LEN; + const unsigned char *nonce = salt + salt_len; + const unsigned char *cipher = nonce + nonce_len; + const size_t cipher_len = blob_len - header_len; + + unsigned char key[crypto_aead_xchacha20poly1305_ietf_KEYBYTES]; + if (!derive_key(password, salt, key)) { + LOG_ERR("crypto_pwhash failed during decrypt"); + sodium_memzero(key, sizeof(key)); + return false; + } + + unsigned char *plain = xmalloc(cipher_len); /* upper bound */ + unsigned long long plen = 0; + int rc = crypto_aead_xchacha20poly1305_ietf_decrypt( + plain, &plen, + NULL, + cipher, cipher_len, + blob, header_len, /* AAD must match what was used in encrypt */ + nonce, + key); + sodium_memzero(key, sizeof(key)); + + if (rc != 0) { + LOG_ERR("decryption failed (wrong password or corrupted file)"); + sodium_memzero(plain, cipher_len); + free(plain); + return false; + } + + *out = plain; + *out_len = (size_t)plen; + return true; +} diff --git a/session-crypto.h b/session-crypto.h new file mode 100644 index 0000000..16aae8f --- /dev/null +++ b/session-crypto.h @@ -0,0 +1,39 @@ +#pragma once + +#include +#include + +/* + * Encrypt and decrypt session scrollback with a password. + * + * File format (binary): + * [magic : 10 bytes "FOOT-ENC1\0"] + * [salt : crypto_pwhash_SALTBYTES (16)] + * [nonce : crypto_aead_xchacha20poly1305_ietf_NPUBBYTES (24)] + * [ciphertext : plaintext_len + crypto_aead_xchacha20poly1305_ietf_ABYTES (16)] + * + * KDF: argon2id at INTERACTIVE ops/mem limits (≈100ms on modern hardware). + * AEAD: XChaCha20-Poly1305 (authenticates the magic+salt+nonce as AAD). + */ + +bool session_crypto_init(void); + +/* + * Encrypts plaintext with the given password. Allocates and returns a buffer + * in *out (caller frees); *out_len receives its size. Returns true on success. + */ +bool session_crypto_encrypt( + const char *password, + const unsigned char *plaintext, size_t plaintext_len, + unsigned char **out, size_t *out_len); + +/* + * Decrypts an encrypted blob (as produced by session_crypto_encrypt) with + * the given password. Allocates *out (caller frees) on success. Returns + * false on bad magic, truncated file, or authentication failure (wrong + * password / corrupted file). + */ +bool session_crypto_decrypt( + const char *password, + const unsigned char *blob, size_t blob_len, + unsigned char **out, size_t *out_len); diff --git a/session-prompt.c b/session-prompt.c new file mode 100644 index 0000000..640bd3b --- /dev/null +++ b/session-prompt.c @@ -0,0 +1,460 @@ +#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); +} diff --git a/session.c b/session.c new file mode 100644 index 0000000..3bbcac7 --- /dev/null +++ b/session.c @@ -0,0 +1,854 @@ +#include "session.h" + +#include +#include +#include +#include +#include +#include +#include + +#include +#include + +#include + +#define LOG_MODULE "session" +#define LOG_ENABLE_DBG 0 +#include "log.h" +#include "xmalloc.h" + +#define JSON_SUFFIX ".json" +#define JSON_SUFFIX_LEN 5 +#define SCROLLBACK_SUFFIX ".scrollback.txt" +#define SCROLLBACK_ENC_SUFFIX ".scrollback.enc" +#define SCROLLBACK_MAX_BYTES (1u << 20) /* 1 MB cap */ + +static int +state_dir(char *buf, size_t buf_sz) +{ + const char *xdg = getenv("XDG_DATA_HOME"); + int n; + if (xdg != NULL && xdg[0] == '/') + n = snprintf(buf, buf_sz, "%s/foot/state", xdg); + else { + const char *home = getenv("HOME"); + if (home == NULL) + return -1; + n = snprintf(buf, buf_sz, "%s/.local/share/foot/state", home); + } + if (n < 0 || (size_t)n >= buf_sz) + return -1; + return n; +} + +static bool +mkdir_p(const char *path) +{ + char tmp[4096]; + size_t len = strlen(path); + if (len >= sizeof(tmp)) + return false; + memcpy(tmp, path, len + 1); + + for (size_t i = 1; i < len; i++) { + if (tmp[i] == '/') { + tmp[i] = '\0'; + if (mkdir(tmp, 0700) < 0 && errno != EEXIST) { + LOG_ERRNO("mkdir(%s)", tmp); + return false; + } + tmp[i] = '/'; + } + } + if (mkdir(tmp, 0700) < 0 && errno != EEXIST) { + LOG_ERRNO("mkdir(%s)", tmp); + return false; + } + return true; +} + +static int +session_path(const char *name, char *buf, size_t buf_sz) +{ + char dir[1024]; + if (state_dir(dir, sizeof(dir)) < 0) + return -1; + int n = snprintf(buf, buf_sz, "%s/%s.json", dir, name); + if (n < 0 || (size_t)n >= buf_sz) + return -1; + return n; +} + +bool +session_name_is_valid(const char *name) +{ + if (name == NULL || name[0] == '\0') + return false; + for (const char *p = name; *p != '\0'; p++) { + char c = *p; + bool ok = (c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') || + (c >= '0' && c <= '9') || c == '.' || c == '_' || c == '-'; + if (!ok) + return false; + } + return true; +} + +void +session_state_free(struct session_state *st) +{ + if (st == NULL) + return; + free(st->cwd); + if (st->argv != NULL) { + for (int i = 0; i < st->argc; i++) + free(st->argv[i]); + free(st->argv); + } + st->cwd = NULL; + st->argv = NULL; + st->argc = 0; +} + +#if defined(__linux__) +static char * +read_link(const char *path) +{ + char *buf = NULL; + size_t cap = 256; + for (;;) { + buf = xrealloc(buf, cap); + ssize_t n = readlink(path, buf, cap - 1); + if (n < 0) { + free(buf); + return NULL; + } + if ((size_t)n < cap - 1) { + buf[n] = '\0'; + return buf; + } + cap *= 2; + if (cap > 1 << 16) { + free(buf); + return NULL; + } + } +} + +static bool +read_cmdline(pid_t pid, int *out_argc, char ***out_argv) +{ + char path[64]; + snprintf(path, sizeof(path), "/proc/%d/cmdline", (int)pid); + + int fd = open(path, O_RDONLY | O_CLOEXEC); + if (fd < 0) + return false; + + char *buf = NULL; + size_t len = 0; + size_t cap = 0; + char chunk[1024]; + for (;;) { + ssize_t n = read(fd, chunk, sizeof(chunk)); + if (n < 0) { + if (errno == EINTR) + continue; + close(fd); + free(buf); + return false; + } + if (n == 0) + break; + if (len + (size_t)n + 1 > cap) { + cap = (cap == 0 ? 1024 : cap * 2); + while (len + (size_t)n + 1 > cap) + cap *= 2; + buf = xrealloc(buf, cap); + } + memcpy(buf + len, chunk, n); + len += n; + } + close(fd); + + if (buf == NULL || len == 0) { + free(buf); + return false; + } + /* /proc//cmdline is NUL-separated; ensure trailing NUL */ + if (buf[len - 1] != '\0') { + buf = xrealloc(buf, len + 1); + buf[len] = '\0'; + len++; + } + + /* Count args */ + int argc = 0; + for (size_t i = 0; i < len; i++) + if (buf[i] == '\0' && (i == 0 || buf[i - 1] != '\0')) + argc++; + + if (argc <= 0) { + free(buf); + return false; + } + + char **argv = xmalloc(sizeof(char *) * (argc + 1)); + int idx = 0; + size_t start = 0; + for (size_t i = 0; i < len && idx < argc; i++) { + if (buf[i] == '\0') { + argv[idx++] = xstrdup(buf + start); + start = i + 1; + } + } + argv[argc] = NULL; + free(buf); + + *out_argc = argc; + *out_argv = argv; + return true; +} +#endif + +bool +session_capture(int ptmx, const char *fallback_cwd, struct session_state *out) +{ + memset(out, 0, sizeof(*out)); + +#if defined(__linux__) + pid_t fg = tcgetpgrp(ptmx); + if (fg > 0) { + char path[64]; + snprintf(path, sizeof(path), "/proc/%d/cwd", (int)fg); + char *cwd = read_link(path); + if (cwd != NULL) + out->cwd = cwd; + + int argc = 0; + char **argv = NULL; + if (read_cmdline(fg, &argc, &argv)) { + /* If the foreground process is a shell (login or otherwise), don't + * try to resurrect it via argv — let the user's normal shell + * launch take over. We only restore non-shell processes. */ + const char *base = argv[0]; + const char *slash = strrchr(base, '/'); + if (slash != NULL) + base = slash + 1; + const char *shells[] = { + "bash", "zsh", "fish", "sh", "dash", "ksh", "tcsh", "csh", + "-bash", "-zsh", "-fish", "-sh", "-dash" + }; + bool is_shell = false; + for (size_t i = 0; i < sizeof(shells) / sizeof(shells[0]); i++) { + if (strcmp(base, shells[i]) == 0) { + is_shell = true; + break; + } + } + if (is_shell) { + for (int i = 0; i < argc; i++) + free(argv[i]); + free(argv); + } else { + out->argc = argc; + out->argv = argv; + } + } + } +#endif + + if (out->cwd == NULL && fallback_cwd != NULL) + out->cwd = xstrdup(fallback_cwd); + + return out->cwd != NULL; +} + +/* ------------- JSON encoding/decoding (minimal, hand-rolled) ------------- */ + +static void +json_escape(FILE *fp, const char *s) +{ + fputc('"', fp); + for (const char *p = s; *p != '\0'; p++) { + unsigned char c = (unsigned char)*p; + switch (c) { + case '"': fputs("\\\"", fp); break; + case '\\': fputs("\\\\", fp); break; + case '\b': fputs("\\b", fp); break; + case '\f': fputs("\\f", fp); break; + case '\n': fputs("\\n", fp); break; + case '\r': fputs("\\r", fp); break; + case '\t': fputs("\\t", fp); break; + default: + if (c < 0x20) + fprintf(fp, "\\u%04x", c); + else + fputc(c, fp); + } + } + fputc('"', fp); +} + +bool +session_save(const char *name, const struct session_state *st) +{ + if (!session_name_is_valid(name)) { + LOG_ERR("invalid session name: %s", name == NULL ? "(null)" : name); + return false; + } + if (st == NULL || st->cwd == NULL) { + LOG_ERR("session_save: missing cwd"); + return false; + } + + char dir[1024]; + if (state_dir(dir, sizeof(dir)) < 0) { + LOG_ERR("could not resolve state directory"); + return false; + } + if (!mkdir_p(dir)) + return false; + + char path[1280]; + if (session_path(name, path, sizeof(path)) < 0) + return false; + + char tmp_path[1408]; + snprintf(tmp_path, sizeof(tmp_path), "%s.tmp", path); + + FILE *fp = fopen(tmp_path, "we"); + if (fp == NULL) { + LOG_ERRNO("fopen(%s)", tmp_path); + return false; + } + + fputs("{\n \"cwd\": ", fp); + json_escape(fp, st->cwd); + if (st->argc > 0 && st->argv != NULL) { + fputs(",\n \"argv\": [", fp); + for (int i = 0; i < st->argc; i++) { + if (i > 0) + fputs(", ", fp); + json_escape(fp, st->argv[i]); + } + fputc(']', fp); + } + fputs("\n}\n", fp); + + if (fflush(fp) != 0 || fsync(fileno(fp)) != 0) { + LOG_ERRNO("fsync(%s)", tmp_path); + fclose(fp); + unlink(tmp_path); + return false; + } + fclose(fp); + + if (rename(tmp_path, path) < 0) { + LOG_ERRNO("rename(%s -> %s)", tmp_path, path); + unlink(tmp_path); + return false; + } + + LOG_INFO("session saved: %s", path); + return true; +} + +/* ------------- JSON parsing (just enough for our schema) ------------- */ + +struct parser { + const char *p; + const char *end; +}; + +static void +skip_ws(struct parser *pa) +{ + while (pa->p < pa->end) { + char c = *pa->p; + if (c == ' ' || c == '\t' || c == '\n' || c == '\r') + pa->p++; + else + break; + } +} + +static bool +parse_string(struct parser *pa, char **out) +{ + skip_ws(pa); + if (pa->p >= pa->end || *pa->p != '"') + return false; + pa->p++; + + size_t cap = 64; + size_t len = 0; + char *buf = xmalloc(cap); + + while (pa->p < pa->end && *pa->p != '"') { + char c = *pa->p++; + char decoded; + if (c == '\\') { + if (pa->p >= pa->end) { free(buf); return false; } + char esc = *pa->p++; + switch (esc) { + case '"': decoded = '"'; break; + case '\\': decoded = '\\'; break; + case '/': decoded = '/'; break; + case 'b': decoded = '\b'; break; + case 'f': decoded = '\f'; break; + case 'n': decoded = '\n'; break; + case 'r': decoded = '\r'; break; + case 't': decoded = '\t'; break; + case 'u': { + if (pa->end - pa->p < 4) { free(buf); return false; } + unsigned val = 0; + for (int i = 0; i < 4; i++) { + char h = pa->p[i]; + val <<= 4; + if (h >= '0' && h <= '9') val |= h - '0'; + else if (h >= 'a' && h <= 'f') val |= h - 'a' + 10; + else if (h >= 'A' && h <= 'F') val |= h - 'A' + 10; + else { free(buf); return false; } + } + pa->p += 4; + if (val < 0x80) { + decoded = (char)val; + } else { + /* UTF-8 encode codepoint (no surrogate-pair handling) */ + char enc[4]; + int n; + if (val < 0x800) { + enc[0] = (char)(0xc0 | (val >> 6)); + enc[1] = (char)(0x80 | (val & 0x3f)); + n = 2; + } else { + enc[0] = (char)(0xe0 | (val >> 12)); + enc[1] = (char)(0x80 | ((val >> 6) & 0x3f)); + enc[2] = (char)(0x80 | (val & 0x3f)); + n = 3; + } + if (len + (size_t)n + 1 > cap) { + while (len + (size_t)n + 1 > cap) cap *= 2; + buf = xrealloc(buf, cap); + } + memcpy(buf + len, enc, n); + len += n; + continue; + } + break; + } + default: free(buf); return false; + } + } else { + decoded = c; + } + if (len + 2 > cap) { + cap *= 2; + buf = xrealloc(buf, cap); + } + buf[len++] = decoded; + } + if (pa->p >= pa->end) { free(buf); return false; } + pa->p++; /* consume closing quote */ + buf[len] = '\0'; + *out = buf; + return true; +} + +static bool +expect_char(struct parser *pa, char c) +{ + skip_ws(pa); + if (pa->p >= pa->end || *pa->p != c) + return false; + pa->p++; + return true; +} + +bool +session_load(const char *name, struct session_state *out) +{ + memset(out, 0, sizeof(*out)); + + if (!session_name_is_valid(name)) { + LOG_ERR("invalid session name: %s", name == NULL ? "(null)" : name); + return false; + } + + char path[1280]; + if (session_path(name, path, sizeof(path)) < 0) + return false; + + int fd = open(path, O_RDONLY | O_CLOEXEC); + if (fd < 0) { + LOG_ERRNO("open(%s)", path); + return false; + } + struct stat sb; + if (fstat(fd, &sb) < 0 || sb.st_size <= 0 || sb.st_size > (off_t)(1 << 20)) { + LOG_ERR("session file %s has unexpected size", path); + close(fd); + return false; + } + char *data = xmalloc(sb.st_size + 1); + ssize_t got = 0; + while (got < sb.st_size) { + ssize_t n = read(fd, data + got, sb.st_size - got); + if (n < 0) { + if (errno == EINTR) continue; + free(data); + close(fd); + return false; + } + if (n == 0) break; + got += n; + } + close(fd); + data[got] = '\0'; + + struct parser pa = { .p = data, .end = data + got }; + bool ok = false; + + if (!expect_char(&pa, '{')) goto done; + + for (;;) { + skip_ws(&pa); + if (pa.p < pa.end && *pa.p == '}') { pa.p++; break; } + + char *key = NULL; + if (!parse_string(&pa, &key)) goto done; + if (!expect_char(&pa, ':')) { free(key); goto done; } + + if (strcmp(key, "cwd") == 0) { + free(key); + if (!parse_string(&pa, &out->cwd)) goto done; + } else if (strcmp(key, "argv") == 0) { + free(key); + if (!expect_char(&pa, '[')) goto done; + int cap = 4; + out->argv = xmalloc(sizeof(char *) * cap); + out->argc = 0; + for (;;) { + skip_ws(&pa); + if (pa.p < pa.end && *pa.p == ']') { pa.p++; break; } + if (out->argc + 1 >= cap) { + cap *= 2; + out->argv = xrealloc(out->argv, sizeof(char *) * cap); + } + if (!parse_string(&pa, &out->argv[out->argc])) goto done; + out->argc++; + skip_ws(&pa); + if (pa.p < pa.end && *pa.p == ',') { pa.p++; continue; } + } + out->argv[out->argc] = NULL; + } else { + free(key); + /* unknown key: skip a string value */ + char *junk = NULL; + if (!parse_string(&pa, &junk)) goto done; + free(junk); + } + + skip_ws(&pa); + if (pa.p < pa.end && *pa.p == ',') { pa.p++; continue; } + } + + ok = out->cwd != NULL; + +done: + free(data); + if (!ok) + session_state_free(out); + return ok; +} + +/* ------------- listing ------------- */ + +static int +cmp_names(const void *a, const void *b) +{ + return strcmp(*(const char *const *)a, *(const char *const *)b); +} + +char ** +session_list(size_t *out_count) +{ + *out_count = 0; + + char dir[1024]; + if (state_dir(dir, sizeof(dir)) < 0) + return NULL; + + DIR *d = opendir(dir); + if (d == NULL) + return NULL; + + size_t cap = 8; + size_t count = 0; + char **names = xmalloc(sizeof(char *) * cap); + + struct dirent *de; + while ((de = readdir(d)) != NULL) { + const char *n = de->d_name; + size_t nlen = strlen(n); + if (nlen <= JSON_SUFFIX_LEN) + continue; + if (strcmp(n + nlen - JSON_SUFFIX_LEN, JSON_SUFFIX) != 0) + continue; + char *stripped = xmalloc(nlen - JSON_SUFFIX_LEN + 1); + memcpy(stripped, n, nlen - JSON_SUFFIX_LEN); + stripped[nlen - JSON_SUFFIX_LEN] = '\0'; + if (!session_name_is_valid(stripped)) { + free(stripped); + continue; + } + if (count >= cap) { + cap *= 2; + names = xrealloc(names, sizeof(char *) * cap); + } + names[count++] = stripped; + } + closedir(d); + + qsort(names, count, sizeof(char *), cmp_names); + + *out_count = count; + return names; +} + +bool +session_exists(const char *name) +{ + if (!session_name_is_valid(name)) + return false; + char path[1280]; + if (session_path(name, path, sizeof(path)) < 0) + return false; + return access(path, F_OK) == 0; +} + +bool +session_delete(const char *name) +{ + if (!session_name_is_valid(name)) + return false; + char path[1280]; + if (session_path(name, path, sizeof(path)) < 0) + return false; + if (unlink(path) < 0) { + LOG_ERRNO("unlink(%s)", path); + return false; + } + session_delete_scrollback(name); + session_delete_enc_scrollback(name); + LOG_INFO("session deleted: %s", path); + return true; +} + +static int +scrollback_path(const char *name, char *buf, size_t buf_sz) +{ + char dir[1024]; + if (state_dir(dir, sizeof(dir)) < 0) + return -1; + int n = snprintf(buf, buf_sz, "%s/%s%s", dir, name, SCROLLBACK_SUFFIX); + if (n < 0 || (size_t)n >= buf_sz) + return -1; + return n; +} + +bool +session_load_scrollback(const char *name, char **out_text, size_t *out_len) +{ + *out_text = NULL; + *out_len = 0; + if (!session_name_is_valid(name)) + return false; + + char path[1280]; + if (scrollback_path(name, path, sizeof(path)) < 0) + return false; + + int fd = open(path, O_RDONLY | O_CLOEXEC); + if (fd < 0) + return false; + + struct stat sb; + if (fstat(fd, &sb) < 0 || sb.st_size < 0 || + (size_t)sb.st_size > SCROLLBACK_MAX_BYTES * 4 /* generous on load */) { + close(fd); + return false; + } + if (sb.st_size == 0) { + close(fd); + return false; + } + + char *data = xmalloc(sb.st_size + 1); + ssize_t got = 0; + while (got < sb.st_size) { + ssize_t n = read(fd, data + got, sb.st_size - got); + if (n < 0) { + if (errno == EINTR) continue; + free(data); + close(fd); + return false; + } + if (n == 0) break; + got += n; + } + close(fd); + data[got] = '\0'; + + *out_text = data; + *out_len = (size_t)got; + return true; +} + +void +session_delete_scrollback(const char *name) +{ + if (!session_name_is_valid(name)) + return; + char path[1280]; + if (scrollback_path(name, path, sizeof(path)) < 0) + return; + unlink(path); +} + +static int +enc_path(const char *name, char *buf, size_t buf_sz) +{ + char dir[1024]; + if (state_dir(dir, sizeof(dir)) < 0) + return -1; + int n = snprintf(buf, buf_sz, "%s/%s%s", dir, name, SCROLLBACK_ENC_SUFFIX); + if (n < 0 || (size_t)n >= buf_sz) + return -1; + return n; +} + +bool +session_has_enc_scrollback(const char *name) +{ + if (!session_name_is_valid(name)) + return false; + char path[1280]; + if (enc_path(name, path, sizeof(path)) < 0) + return false; + return access(path, F_OK) == 0; +} + +void +session_delete_enc_scrollback(const char *name) +{ + if (!session_name_is_valid(name)) + return; + char path[1280]; + if (enc_path(name, path, sizeof(path)) < 0) + return; + unlink(path); +} + +bool +session_write_enc_blob(const char *name, const unsigned char *blob, size_t len) +{ + if (!session_name_is_valid(name)) + return false; + char dir[1024]; + if (state_dir(dir, sizeof(dir)) < 0) + return false; + if (!mkdir_p(dir)) + return false; + char path[1280]; + if (enc_path(name, path, sizeof(path)) < 0) + return false; + char tmp_path[1408]; + snprintf(tmp_path, sizeof(tmp_path), "%s.tmp", path); + + /* 0600 — encrypted but still reduces accidental exposure. */ + int fd = open(tmp_path, O_WRONLY | O_CREAT | O_TRUNC | O_CLOEXEC, 0600); + if (fd < 0) { + LOG_ERRNO("open(%s)", tmp_path); + return false; + } + size_t written = 0; + while (written < len) { + ssize_t n = write(fd, blob + written, len - written); + if (n < 0) { + if (errno == EINTR) continue; + LOG_ERRNO("write(%s)", tmp_path); + close(fd); + unlink(tmp_path); + return false; + } + written += n; + } + if (fsync(fd) < 0) { + LOG_ERRNO("fsync(%s)", tmp_path); + close(fd); + unlink(tmp_path); + return false; + } + close(fd); + if (rename(tmp_path, path) < 0) { + LOG_ERRNO("rename(%s -> %s)", tmp_path, path); + unlink(tmp_path); + return false; + } + return true; +} + +bool +session_read_enc_blob(const char *name, unsigned char **out, size_t *out_len) +{ + *out = NULL; + *out_len = 0; + if (!session_name_is_valid(name)) + return false; + char path[1280]; + if (enc_path(name, path, sizeof(path)) < 0) + return false; + int fd = open(path, O_RDONLY | O_CLOEXEC); + if (fd < 0) + return false; + struct stat sb; + if (fstat(fd, &sb) < 0 || sb.st_size <= 0 || + (size_t)sb.st_size > SCROLLBACK_MAX_BYTES * 4) { + close(fd); + return false; + } + unsigned char *buf = xmalloc(sb.st_size); + ssize_t got = 0; + while (got < sb.st_size) { + ssize_t n = read(fd, buf + got, sb.st_size - got); + if (n < 0) { + if (errno == EINTR) continue; + free(buf); + close(fd); + return false; + } + if (n == 0) break; + got += n; + } + close(fd); + if (got != sb.st_size) { + free(buf); + return false; + } + *out = buf; + *out_len = (size_t)got; + return true; +} + +void +session_free_names(char **names, size_t count) +{ + if (names == NULL) + return; + for (size_t i = 0; i < count; i++) + free(names[i]); + free(names); +} diff --git a/session.h b/session.h new file mode 100644 index 0000000..2b1921a --- /dev/null +++ b/session.h @@ -0,0 +1,92 @@ +#pragma once + +#include +#include + +/* + * Persistent terminal session state. + * + * Sessions are stored as JSON files under $XDG_DATA_HOME/foot/state/.json + * (defaulting to ~/.local/share/foot/state). Each file records the cwd of the + * foreground process on the pty plus its argv, so a session can be resurrected + * in a new tab. + */ + +struct session_state { + char *cwd; + int argc; + char **argv; /* NULL-terminated for convenience; argc does not count the terminator */ +}; + +void session_state_free(struct session_state *st); + +/* + * Inspect the pty for its foreground process. On Linux, walks /proc to grab + * cmdline + cwd. Falls back to fallback_cwd and a NULL argv on other platforms + * or when introspection fails (the caller will then save cwd only and let the + * shell start fresh on resume). Returns true if at least cwd is populated. + */ +bool session_capture(int ptmx, const char *fallback_cwd, + struct session_state *out); + +/* Persist to /.json. Creates the directory if missing. */ +bool session_save(const char *name, const struct session_state *st); + +/* Read /.json. */ +bool session_load(const char *name, struct session_state *out); + +/* + * List existing sessions. Returns a malloc'd array of malloc'd names + * (no .json suffix), sorted. Caller frees with session_free_names(). + */ +char **session_list(size_t *out_count); +void session_free_names(char **names, size_t count); + +/* Returns true if name is non-empty and contains only [A-Za-z0-9._-]. */ +bool session_name_is_valid(const char *name); + +/* Remove /.json. Returns true on success. */ +bool session_delete(const char *name); + +/* True if /.json exists. */ +bool session_exists(const char *name); + +struct terminal; + +/* + * Read /.scrollback.txt into out_text and out_len. Returns + * false (with out_text set to NULL) if the file is missing or fails sanity + * checks. Caller frees out_text. + */ +bool session_load_scrollback(const char *name, char **out_text, size_t *out_len); + +/* Best-effort delete of the scrollback sidecar (used when removing a session). */ +void session_delete_scrollback(const char *name); + +/* Encrypted-sidecar I/O. /.scrollback.enc */ +bool session_write_enc_blob(const char *name, + const unsigned char *blob, size_t len); +bool session_read_enc_blob(const char *name, + unsigned char **out, size_t *out_len); +bool session_has_enc_scrollback(const char *name); +void session_delete_enc_scrollback(const char *name); + +struct terminal; + +/* + * Called from search.c when the user presses Enter while the search bar is + * in a session prompt mode. Reads the typed name from term->search.buf and + * performs the requested save or load, then dismisses the prompt. + */ +void session_prompt_commit(struct terminal *term); +void session_prompt_cancel(struct terminal *term); + +/* Finalize a save after the user has confirmed overwrite (y). */ +void session_prompt_confirm_overwrite(struct terminal *term); + +/* Picker (session-load) helpers. */ +void session_picker_init(struct terminal *term); +void session_picker_free(struct terminal *term); +void session_picker_refilter(struct terminal *term); +void session_picker_move(struct terminal *term, int delta); /* +1/-1 etc */ +bool session_picker_delete_selected(struct terminal *term); diff --git a/terminal.c b/terminal.c index aada5fb..2ed756f 100644 --- a/terminal.c +++ b/terminal.c @@ -1511,6 +1511,17 @@ struct terminal *term_tab_new(struct terminal *primary, int argc, char *const *argv, const char *const *envp, void (*shutdown_cb)(void *data, int exit_code), void *shutdown_data) { + return term_tab_new_with_cwd(primary, NULL, argc, argv, envp, shutdown_cb, + shutdown_data); +} + +struct terminal *term_tab_new_with_cwd(struct terminal *primary, + const char *override_cwd, int argc, + char *const *argv, + const char *const *envp, + void (*shutdown_cb)(void *data, + int exit_code), + void *shutdown_data) { struct wl_window *win = primary->window; if (win->tab_count >= TAB_MAX) { @@ -1725,9 +1736,12 @@ struct terminal *term_tab_new(struct terminal *primary, int argc, .cb_data = shutdown_data, }, .foot_exe = xstrdup(primary->foot_exe), - .cwd = xstrdup(conf->tabs.inherit_cwd - ? win->term->cwd - : (getenv("HOME") != NULL ? getenv("HOME") : "/")), + .cwd = xstrdup(override_cwd != NULL + ? override_cwd + : (conf->tabs.inherit_cwd + ? win->term->cwd + : (getenv("HOME") != NULL ? getenv("HOME") + : "/"))), .grapheme_shaping = conf->tweak.grapheme_shaping, #if defined(FOOT_IME_ENABLED) && FOOT_IME_ENABLED .ime_enabled = true, @@ -1764,6 +1778,10 @@ struct terminal *term_tab_new(struct terminal *primary, int argc, add_utmp_record(conf, reaper, ptmx); + /* Initialize window title to the configured default; otherwise a freshly + * spawned child sending CSI 22 t (push title) sees a NULL title. */ + term_set_window_title(term, conf->title); + if ((term->slave = slave_spawn( term->ptmx, argc, term->cwd, argv, envp, &conf->env_vars, conf->term, conf->shell, conf->login_shell, &conf->notifications)) == -1) { diff --git a/terminal.h b/terminal.h index 985d2ae..e06b068 100644 --- a/terminal.h +++ b/terminal.h @@ -344,6 +344,15 @@ enum selection_direction {SELECTION_UNDIR, SELECTION_LEFT, SELECTION_RIGHT}; enum selection_scroll_direction {SELECTION_SCROLL_NOT, SELECTION_SCROLL_UP, SELECTION_SCROLL_DOWN}; enum search_direction { SEARCH_BACKWARD_SAME_POSITION, SEARCH_BACKWARD, SEARCH_FORWARD }; enum search_case_mode { SEARCH_CASE_SMART, SEARCH_CASE_SENSITIVE, SEARCH_CASE_INSENSITIVE }; +enum search_mode { + SEARCH_MODE_NORMAL, + SEARCH_MODE_SESSION_SAVE, + SEARCH_MODE_SESSION_LOAD, + SEARCH_MODE_SESSION_OVERWRITE_CONFIRM, + SEARCH_MODE_SESSION_SAVE_SECURE_NAME, + SEARCH_MODE_SESSION_SAVE_SECURE_PASSWORD, + SEARCH_MODE_SESSION_LOAD_PASSWORD, +}; struct ptmx_buffer { void *data; @@ -626,6 +635,7 @@ struct terminal { size_t len; size_t sz; size_t cursor; + enum search_mode mode; int original_view; bool view_followed_offset; @@ -865,6 +875,24 @@ struct terminal { char *foot_exe; char *cwd; + /* Active only while term->search.mode == SEARCH_MODE_SESSION_LOAD */ + struct { + char **all; /* All session names from disk */ + size_t all_count; + size_t *filtered; /* indices into all[] matching current input */ + size_t filtered_count; + size_t filtered_cap; + size_t sel; /* index within filtered[] */ + } session_picker; + + /* Name held across a multi-step session flow (overwrite confirm, secure + * save name->password, load password). */ + char *session_pending_name; + bool session_pending_secure; /* set when overwrite-confirm precedes a secure save */ + /* For LOAD_PASSWORD: heap-allocated session_state pulled from JSON. The + * new tab isn't spawned until decryption succeeds, so we hold it here. */ + void *session_pending_load_state; + bool grapheme_shaping; bool size_notifications; }; @@ -882,6 +910,15 @@ struct terminal *term_tab_new( int argc, char *const *argv, const char *const *envp, void (*shutdown_cb)(void *data, int exit_code), void *shutdown_data); +/* + * Like term_tab_new but with an explicit override for the new tab's cwd. + * If override_cwd is NULL behaves identically to term_tab_new. + */ +struct terminal *term_tab_new_with_cwd( + struct terminal *primary, const char *override_cwd, + int argc, char *const *argv, const char *const *envp, + void (*shutdown_cb)(void *data, int exit_code), void *shutdown_data); + void term_tab_switch(struct wl_window *win, size_t idx); void term_tab_close(struct terminal *term); size_t term_tab_bar_hit_test(const struct terminal *term, int x, int y);