-- SOPS integration for automatic encryption/decryption of secrets files -- This module sets up autocmds to handle .secrets.yaml files transparently local sops_group = vim.api.nvim_create_augroup("SopsEncryption", { clear = true }) -- Pattern matching for secrets files 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 if vim.fn.match(filepath, vim.fn.glob2regpat(pattern)) ~= -1 then return true end end return false end -- Helper function to detach all LSP clients from a buffer -- This prevents LSP sync errors when SOPS replaces the entire buffer content local function detach_lsp_clients(bufnr) local clients = vim.lsp.get_clients({ bufnr = bufnr }) for _, client in ipairs(clients) do vim.lsp.buf_detach_client(bufnr, client.id) end end -- Set filetype before reading to enable syntax highlighting vim.api.nvim_create_autocmd("BufReadPre", { group = sops_group, pattern = secrets_patterns, callback = function(args) -- Set filetype to yaml before the file is read so syntax highlighting works vim.bo[args.buf].filetype = "yaml" end, }) -- Decrypt file after reading vim.api.nvim_create_autocmd("BufReadPost", { group = sops_group, pattern = secrets_patterns, callback = function(args) local filepath = vim.api.nvim_buf_get_name(args.buf) -- 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 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 -- Detach LSP clients BEFORE replacing buffer to prevent sync errors detach_lsp_clients(args.buf) -- Replace buffer content with decrypted content -- Strip trailing newline to avoid adding extra empty line vim.api.nvim_buf_set_lines(args.buf, 0, -1, false, vim.split(result:gsub("\n$", ""), "\n")) -- Mark buffer as not modified (since we just loaded it) vim.bo[args.buf].modified = false -- Restore cursor position pcall(vim.api.nvim_win_set_cursor, 0, cursor_pos) -- Disable swap, backup, and undo files for security vim.bo[args.buf].swapfile = false vim.bo[args.buf].backup = false vim.bo[args.buf].writebackup = false vim.bo[args.buf].undofile = false -- Ensure filetype is set to yaml for syntax highlighting vim.bo[args.buf].filetype = "yaml" vim.notify("SOPS: File decrypted successfully", vim.log.levels.INFO) else vim.notify("SOPS: Failed to decrypt file: " .. result, vim.log.levels.ERROR) end end end, }) -- Override write command for secrets files vim.api.nvim_create_autocmd("BufWriteCmd", { group = sops_group, pattern = secrets_patterns, callback = function(args) local filepath = vim.api.nvim_buf_get_name(args.buf) if not is_secrets_file(filepath) then return end -- Guard against double-execution if currently_saving[filepath] then return end currently_saving[filepath] = true -- Use pcall to ensure guard is always cleared, even on unexpected errors local ok, err = pcall(function() -- Get current buffer content local lines = vim.api.nvim_buf_get_lines(args.buf, 0, -1, false) local content = table.concat(lines, "\n") -- 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 ) return 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_%d", dir, filename, os.time(), vim.loop.hrtime() % 1000000) -- 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) return end temp_f:write(content .. "\n") temp_f:close() -- 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("timeout %d sops --encrypt --filename-override %s %s", SOPS_TIMEOUT, vim.fn.shellescape(filepath), vim.fn.shellescape(temp_file)) local encrypted = vim.fn.system(cmd) local sops_exit_code = vim.v.shell_error -- Always clean up temp file, even on error os.remove(temp_file) -- Check for timeout (exit code 124 from timeout command) if sops_exit_code == 124 then vim.notify( string.format("SOPS: Encryption timed out after %d seconds.", SOPS_TIMEOUT), vim.log.levels.ERROR ) return end if sops_exit_code == 0 then -- Write encrypted content directly to file local file, file_err = io.open(filepath, "w") if file then local success, write_err = file:write(encrypted) file:close() if success then -- Mark buffer as saved vim.bo[args.buf].modified = false vim.notify("SOPS: File encrypted and saved successfully", vim.log.levels.INFO) -- 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) -- Detach LSP clients BEFORE replacing buffer to prevent sync errors detach_lsp_clients(args.buf) -- Replace buffer with decrypted content -- Strip trailing newline to avoid adding extra empty line vim.api.nvim_buf_set_lines(args.buf, 0, -1, false, vim.split(decrypted:gsub("\n$", ""), "\n")) -- Mark as not modified since we just saved vim.bo[args.buf].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) end else vim.notify("SOPS: Failed to open file for writing: " .. (file_err or "unknown error"), vim.log.levels.ERROR) end else vim.notify("SOPS: Failed to encrypt file - NOT SAVED! Error: " .. encrypted, vim.log.levels.ERROR) end end) -- Always clear guard, even if pcall caught an error currently_saving[filepath] = nil -- Re-throw unexpected errors so they're visible if not ok then vim.notify("SOPS: Unexpected error during save: " .. tostring(err), vim.log.levels.ERROR) end end, }) -- Warn when leaving a secrets buffer with unsaved changes vim.api.nvim_create_autocmd("BufLeave", { group = sops_group, pattern = secrets_patterns, callback = function(args) if vim.bo[args.buf].modified then vim.notify("Warning: Unsaved changes in secrets file!", vim.log.levels.WARN) end end, })