From b6d44b5a20d49dbd8a3460893756089cb403060e Mon Sep 17 00:00:00 2001 From: Dominik Polakovics Date: Wed, 22 Oct 2025 19:14:36 +0200 Subject: [PATCH] fix: nvim sops lua --- .../modules/development/nvim/config/sops.lua | 173 +++++++++++++----- 1 file changed, 124 insertions(+), 49 deletions(-) diff --git a/hosts/nb/modules/development/nvim/config/sops.lua b/hosts/nb/modules/development/nvim/config/sops.lua index 662f09a..d642d05 100644 --- a/hosts/nb/modules/development/nvim/config/sops.lua +++ b/hosts/nb/modules/development/nvim/config/sops.lua @@ -9,6 +9,17 @@ local secrets_patterns = { "*secrets*.yaml", } +-- Size limits to prevent memory issues and UI freezes +-- 5MB encrypted file limit (typical secrets files are <100KB) +local MAX_SOPS_FILE_SIZE = 5 * 1024 * 1024 -- 5MB +-- 10MB decrypted content limit (allows for expansion during decryption) +local MAX_DECRYPTED_SIZE = 10 * 1024 * 1024 -- 10MB +-- Timeout for SOPS operations to prevent infinite hangs +local SOPS_TIMEOUT = 30 -- seconds + +-- Guard against double-execution (patterns overlap, causing callback to fire twice) +local currently_saving = {} + -- Helper function to check if file matches secrets pattern local function is_secrets_file(filepath) for _, pattern in ipairs(secrets_patterns) do @@ -38,13 +49,47 @@ vim.api.nvim_create_autocmd("BufReadPost", { -- Only decrypt if file exists and has content if vim.fn.filereadable(filepath) == 1 and vim.fn.getfsize(filepath) > 0 then + -- Check file size before attempting to decrypt + local filesize = vim.fn.getfsize(filepath) + if filesize > MAX_SOPS_FILE_SIZE then + local size_mb = string.format("%.1f", filesize / (1024 * 1024)) + local limit_mb = string.format("%.1f", MAX_SOPS_FILE_SIZE / (1024 * 1024)) + vim.notify( + string.format("SOPS: File too large (%sMB > %sMB limit). Skipping decryption to prevent freeze.", size_mb, limit_mb), + vim.log.levels.WARN + ) + return + end + -- Save cursor position local cursor_pos = vim.api.nvim_win_get_cursor(0) - -- Decrypt file content - local result = vim.fn.system("sops --decrypt " .. vim.fn.shellescape(filepath)) + -- Decrypt file content with timeout to prevent hanging + local cmd = string.format("timeout %d sops --decrypt %s", SOPS_TIMEOUT, vim.fn.shellescape(filepath)) + local result = vim.fn.system(cmd) + local exit_code = vim.v.shell_error + + -- Check for timeout (exit code 124 from timeout command) + if exit_code == 124 then + vim.notify( + string.format("SOPS: Decryption timed out after %d seconds. Check your SOPS configuration and age keys.", SOPS_TIMEOUT), + vim.log.levels.ERROR + ) + return + end + + if exit_code == 0 then + -- Validate decrypted content size before loading into buffer + if #result > MAX_DECRYPTED_SIZE then + local size_mb = string.format("%.1f", #result / (1024 * 1024)) + local limit_mb = string.format("%.1f", MAX_DECRYPTED_SIZE / (1024 * 1024)) + vim.notify( + string.format("SOPS: Decrypted content too large (%sMB > %sMB limit). Skipping to prevent freeze.", size_mb, limit_mb), + vim.log.levels.WARN + ) + return + end - if vim.v.shell_error == 0 then -- Replace buffer content with decrypted content vim.api.nvim_buf_set_lines(0, 0, -1, false, vim.split(result, "\n")) @@ -71,8 +116,8 @@ vim.api.nvim_create_autocmd("BufReadPost", { end, }) --- Encrypt file before writing -vim.api.nvim_create_autocmd("BufWritePre", { +-- Override write command for secrets files +vim.api.nvim_create_autocmd("BufWriteCmd", { group = sops_group, pattern = secrets_patterns, callback = function(args) @@ -83,58 +128,88 @@ vim.api.nvim_create_autocmd("BufWritePre", { local lines = vim.api.nvim_buf_get_lines(0, 0, -1, false) local content = table.concat(lines, "\n") - -- Encrypt content using SOPS - local encrypted = vim.fn.system("sops --encrypt /dev/stdin", content) - - if vim.v.shell_error == 0 then - -- Write encrypted content directly to file - local file = io.open(filepath, "w") - if file then - file:write(encrypted) - file:close() - - -- Mark buffer as saved (prevent Vim from writing again) - vim.bo.modified = false - - vim.notify("SOPS: File encrypted and saved successfully", vim.log.levels.INFO) - else - vim.notify("SOPS: Failed to write encrypted file", vim.log.levels.ERROR) - end - else - vim.notify("SOPS: Failed to encrypt file: " .. encrypted, vim.log.levels.ERROR) - -- Prevent write on encryption failure - return true + -- Check buffer content size before encrypting + if #content > MAX_DECRYPTED_SIZE then + local size_mb = string.format("%.1f", #content / (1024 * 1024)) + local limit_mb = string.format("%.1f", MAX_DECRYPTED_SIZE / (1024 * 1024)) + vim.notify( + string.format("SOPS: Buffer content too large (%sMB > %sMB limit). Cannot encrypt.", size_mb, limit_mb), + vim.log.levels.ERROR + ) + -- Don't write anything, leave buffer marked as modified + return end - -- Prevent default write behavior since we handled it - return true - end - end, -}) + -- Encrypt content using SOPS via temporary file in same directory + -- This avoids /dev/stdin issues while keeping secrets secure (not in /tmp) + local dir = vim.fn.fnamemodify(filepath, ":h") + local filename = vim.fn.fnamemodify(filepath, ":t") + local temp_file = string.format("%s/.%s.sops_tmp_%d", dir, filename, os.time()) --- Re-decrypt after writing to show plaintext in buffer -vim.api.nvim_create_autocmd("BufWritePost", { - group = sops_group, - pattern = secrets_patterns, - callback = function(args) - local filepath = vim.fn.expand("%:p") + -- Write plaintext content to temp file + local temp_f, temp_err = io.open(temp_file, "w") + if not temp_f then + vim.notify("SOPS: Failed to create temp file: " .. (temp_err or "unknown error"), vim.log.levels.ERROR) + -- Don't write anything, leave buffer marked as modified + return + end + temp_f:write(content) + temp_f:close() - if is_secrets_file(filepath) and vim.fn.filereadable(filepath) == 1 then - -- Decrypt and reload buffer content - local result = vim.fn.system("sops --decrypt " .. vim.fn.shellescape(filepath)) + -- Encrypt temp file with filename override so SOPS matches .sops.yaml rules + -- Uses real filepath for rule matching, temp file for content + local cmd = string.format("sops --encrypt --filename-override %s %s", + vim.fn.shellescape(filepath), + vim.fn.shellescape(temp_file)) + local encrypted = vim.fn.system(cmd) + local sops_exit_code = vim.v.shell_error - if vim.v.shell_error == 0 then - -- Save cursor position - local cursor_pos = vim.api.nvim_win_get_cursor(0) + -- Always clean up temp file, even on error + os.remove(temp_file) - -- Replace buffer with decrypted content - vim.api.nvim_buf_set_lines(0, 0, -1, false, vim.split(result, "\n")) + if sops_exit_code == 0 then + -- Write encrypted content directly to file + local file, err = io.open(filepath, "w") + if file then + local success, write_err = file:write(encrypted) + file:close() - -- Mark as not modified - vim.bo.modified = false + if success then + -- Mark buffer as saved + vim.bo.modified = false + vim.notify("SOPS: File encrypted and saved successfully", vim.log.levels.INFO) - -- Restore cursor position - pcall(vim.api.nvim_win_set_cursor, 0, cursor_pos) + -- Re-decrypt to show plaintext in buffer + local decrypt_cmd = string.format("timeout %d sops --decrypt %s", SOPS_TIMEOUT, vim.fn.shellescape(filepath)) + local decrypted = vim.fn.system(decrypt_cmd) + local decrypt_exit = vim.v.shell_error + + if decrypt_exit == 0 then + -- Save cursor position + local cursor_pos = vim.api.nvim_win_get_cursor(0) + + -- Replace buffer with decrypted content + vim.api.nvim_buf_set_lines(0, 0, -1, false, vim.split(decrypted, "\n")) + + -- Mark as not modified since we just saved + vim.bo.modified = false + + -- Restore cursor position + pcall(vim.api.nvim_win_set_cursor, 0, cursor_pos) + else + vim.notify("SOPS: Could not re-decrypt after save. Buffer may show encrypted content.", vim.log.levels.WARN) + end + else + vim.notify("SOPS: Failed to write encrypted content: " .. (write_err or "unknown error"), vim.log.levels.ERROR) + -- Don't mark as saved, keep buffer marked as modified + end + else + vim.notify("SOPS: Failed to open file for writing: " .. (err or "unknown error"), vim.log.levels.ERROR) + -- Don't mark as saved, keep buffer marked as modified + end + else + vim.notify("SOPS: Failed to encrypt file - NOT SAVED! Error: " .. encrypted, vim.log.levels.ERROR) + -- Don't write anything, leave buffer marked as modified end end end,