diff --git a/key-visualizer.lua b/key-visualizer.lua index 089b671..b6d03c8 100644 --- a/key-visualizer.lua +++ b/key-visualizer.lua @@ -14,18 +14,29 @@ local function shell_quote(s) return "'" .. s:gsub("'", "'\\''") .. "'" end +local function effective_uid() + local f = io.open("/proc/self/status", "r") + if not f then return nil end + for line in f:lines() do + local uid = line:match("^Uid:%s+%d+%s+(%d+)") + if uid then + f:close() + return uid + end + end + f:close() + return nil +end + local function is_runtime_secure(r) if not r or r == "" then return false end if r == "/tmp" then return false end if r:sub(1, 1) ~= "/" then return false end - local q = shell_quote(r) - -- Must be a directory owned by the current user and, if stat is - -- available, mode 0700 (systemd's XDG_RUNTIME_DIR default). The - -- stat check is skipped when stat is missing so we don't fail-closed - -- on minimal containers. - local cmd = "test -d " .. q .. " && test -O " .. q .. " && { p=$(stat -c %a " .. q .. " 2>/dev/null); [ -z \"$p\" ] || [ \"$p\" = \"700\" ]; }" - local res = os.execute(cmd) - return res == 0 or res == true + -- Shell ownership probes fail from Hyprland's embedded Lua environment. + -- /run/user is root-controlled, so accepting only this process's systemd + -- runtime path still excludes shared or caller-supplied directories. + local uid = effective_uid() + return uid ~= nil and r == "/run/user/" .. uid end local runtime = os.getenv("XDG_RUNTIME_DIR") @@ -38,36 +49,19 @@ if not is_runtime_secure(runtime) then runtime = nil end local STATE_FILE = runtime and (runtime .. "/omarchy-key-visualizer.json") or nil +local SUPER_FLAG = runtime and (runtime .. "/omarchy-key-visualizer-super") or nil local function secure_write(path, content) - if not path then return false end - -- Reject the exact predictable fallback that the security review flagged. - -- A valid XDG_RUNTIME_DIR under /tmp with a random suffix and 0700 is - -- allowed because it passed is_runtime_secure(). - if path == "/tmp/omarchy-key-visualizer.json" or path == "/tmp/omarchy-key-visualizer-super" then return false end - local q = shell_quote(path) - -- Refuse to follow a symlink at the destination (O_NOFOLLOW mitigation). - -- `test ! -L` succeeds when the file does not exist or is not a symlink; - -- it fails only when the destination is a symlink, which we must not follow. - local not_symlink = os.execute("test ! -L " .. q) - if not (not_symlink == 0 or not_symlink == true) then - print("[key-visualizer] refusing to write symlink: " .. path) - return false - end - local tmp = path .. ".tmp" - local f = io.open(tmp, "w") + if not runtime or (path ~= STATE_FILE and path ~= SUPER_FLAG) then return false end + -- FileView watches the existing inode, so replacing the path on every key + -- leaves Quickshell attached to an unlinked file. Updating in place keeps + -- the watcher live. The containing runtime directory is private to the + -- effective user and the two accepted paths are fixed above. + local f = io.open(path, "w") if not f then return false end f:write(content) f:close() - os.execute("chmod 600 " .. shell_quote(tmp) .. " 2>/dev/null") - -- Atomic replace; avoids truncating a file that may have been swapped - -- between the symlink check and the open (TOCTOU mitigation). - local ok = os.rename(tmp, path) - if not ok then - os.execute("rm -f " .. shell_quote(tmp) .. " 2>/dev/null") - return false - end - os.execute("chmod 600 " .. q .. " 2>/dev/null") + os.execute("chmod 600 " .. shell_quote(path) .. " 2>/dev/null") return true end @@ -200,7 +194,6 @@ end -- Super-held flag: the panel/display watches this to know when to capture the -- SUPER+drag on the overlay (instead of a window underneath). Written only on -- transitions so it does not spam the filesystem on every key. -local SUPER_FLAG = runtime and (runtime .. "/omarchy-key-visualizer-super") or nil local last_super = nil local function super_down() return pressed[133] or pressed[134] @@ -213,6 +206,12 @@ local function emit_super() secure_write(SUPER_FLAG, down and "1" or "0") end +-- Create both files when the hook loads. This clears stale state after a +-- compositor restart and gives FileView stable paths to watch before the +-- first keyboard event arrives. +emit() +emit_super() + -- Combos: a combination of keys is treated as a unit. The display only -- updates on key-down (the combo grows as you press) and when the last key -- is released (the empty payload starts the panel's linger with the last