-
-
Notifications
You must be signed in to change notification settings - Fork 625
#1897 fix: resolve hyde paths without erroring on an unset environment #1900
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
5b57b55
52a1e14
21b5e98
ea3988d
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -1,46 +1,101 @@ | ||||||||||||||||||
| -- hyde/path.lua | ||||||||||||||||||
| -- Provides global access to important XDG and HyDE paths | ||||||||||||||||||
| --- @module hyde.path | ||||||||||||||||||
| --- Resolves the XDG and HyDE directories the Hyprland Lua configuration reads | ||||||||||||||||||
| --- from, and exposes them globally as `hyde.path`. | ||||||||||||||||||
| --- | ||||||||||||||||||
| --- This module is loaded before any other part of the configuration, so it has | ||||||||||||||||||
| --- to survive an environment it does not like. A session started outside uwsm | ||||||||||||||||||
| --- — from a TTY, or through a display manager that exports little — can reach | ||||||||||||||||||
| --- it with `HOME` absent and the XDG variables unset or empty. A field that | ||||||||||||||||||
| --- cannot be resolved is left `nil` for the caller to skip; raising here would | ||||||||||||||||||
| --- take the whole configuration down before Hyprland has a window on screen. | ||||||||||||||||||
| --- | ||||||||||||||||||
| --- Usage: | ||||||||||||||||||
| --- local config_file = hyde.path.config and (hyde.path.config .. "/hyde/config.toml") | ||||||||||||||||||
|
|
||||||||||||||||||
| local P = {} | ||||||||||||||||||
|
|
||||||||||||||||||
| P.config = os.getenv("XDG_CONFIG_HOME") or (os.getenv("HOME") .. "/.config") | ||||||||||||||||||
| P.cache = os.getenv("XDG_CACHE_HOME") or (os.getenv("HOME") .. "/.cache") | ||||||||||||||||||
| P.state = (os.getenv("XDG_STATE_HOME") or (os.getenv("HOME") .. "/.local/state")) | ||||||||||||||||||
| local HOME = os.getenv("HOME") | ||||||||||||||||||
|
|
||||||||||||||||||
| -- XDG runtime directory | ||||||||||||||||||
| --- Reads an XDG base directory from the environment. | ||||||||||||||||||
| --- | ||||||||||||||||||
| --- Per the specification a variable that is set but empty counts as unset, and | ||||||||||||||||||
| --- the fallback is derived from `HOME`. | ||||||||||||||||||
| --- | ||||||||||||||||||
| --- @param name string Environment variable holding the directory. | ||||||||||||||||||
| --- @param fallback string Path appended to `HOME` when the variable is unset. | ||||||||||||||||||
| --- @return string|nil path The directory, or nil when `HOME` is absent too. | ||||||||||||||||||
| --- | ||||||||||||||||||
| --- Example: | ||||||||||||||||||
| --- env("XDG_CONFIG_HOME", "/.config") --> "/home/user/.config" | ||||||||||||||||||
| local function env(name, fallback) | ||||||||||||||||||
| local value = os.getenv(name) | ||||||||||||||||||
| if value == nil or value == "" then | ||||||||||||||||||
| return HOME and HOME .. fallback | ||||||||||||||||||
| end | ||||||||||||||||||
|
|
||||||||||||||||||
| return value | ||||||||||||||||||
| end | ||||||||||||||||||
|
|
||||||||||||||||||
| --- @field config string|nil User configuration directory. | ||||||||||||||||||
| P.config = env("XDG_CONFIG_HOME", "/.config") | ||||||||||||||||||
|
|
||||||||||||||||||
| --- @field cache string|nil User cache directory. | ||||||||||||||||||
| P.cache = env("XDG_CACHE_HOME", "/.cache") | ||||||||||||||||||
|
|
||||||||||||||||||
| --- @field state string|nil User state directory. | ||||||||||||||||||
| P.state = env("XDG_STATE_HOME", "/.local/state") | ||||||||||||||||||
|
|
||||||||||||||||||
| --- @field data string|nil User data directory. | ||||||||||||||||||
| P.data = env("XDG_DATA_HOME", "/.local/share") | ||||||||||||||||||
|
|
||||||||||||||||||
| --- @field runtime string|nil Runtime directory. It has no fallback: the spec | ||||||||||||||||||
| --- requires the session to provide one, and inventing a path would put runtime | ||||||||||||||||||
| --- state somewhere that outlives the session. | ||||||||||||||||||
| P.runtime = os.getenv("XDG_RUNTIME_DIR") | ||||||||||||||||||
|
|
||||||||||||||||||
| -- HyDE library directory resolution | ||||||||||||||||||
| local lib_paths = { | ||||||||||||||||||
| os.getenv("HOME") .. "/.local/lib", | ||||||||||||||||||
| "/usr/local/lib", | ||||||||||||||||||
| "/usr/lib", | ||||||||||||||||||
| } | ||||||||||||||||||
| for _, p in ipairs(lib_paths) do | ||||||||||||||||||
| local test = io.popen("[ -d '" .. p .. "' ] && echo 1 || echo 0"):read("*l") | ||||||||||||||||||
| if test == "1" then | ||||||||||||||||||
| P.lib = p | ||||||||||||||||||
| break | ||||||||||||||||||
| --- Reports whether a path is a directory. | ||||||||||||||||||
| --- | ||||||||||||||||||
| --- Plain Lua cannot stat, so this asks the shell. The pipe is closed on every | ||||||||||||||||||
| --- outcome — leaving it open leaks a handle per probe, and this module runs on | ||||||||||||||||||
| --- every configuration reload. | ||||||||||||||||||
| --- | ||||||||||||||||||
| --- @param path string Absolute path to test. | ||||||||||||||||||
| --- @return boolean exists True when the path is a directory. | ||||||||||||||||||
| local function is_directory(path) | ||||||||||||||||||
| local pipe = io.popen("[ -d '" .. path .. "' ] && echo 1 || echo 0") | ||||||||||||||||||
|
Comment on lines
+64
to
+65
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🔒 Security & Privacy | 🟠 Major | ⚡ Quick win 🧩 Analysis chain🏁 Script executed: #!/bin/sh
# Inspect shell command construction without executing repository files.
rg -n -C 3 'io\.popen|shell_quote|is_directory' \
Configs/.local/share/hypr/lua/hyde/path.luaRepository: HyDE-Project/HyDE Length of output: 901 🏁 Script executed: #!/bin/sh
set -eu
echo "== path.lua relevant section =="
sed -n '1,130p' Configs/.local/share/hypr/lua/hyde/path.lua
echo
echo "== is_directory call sites / exposed API =="
rg -n -C 2 'is_directory|first_directory|os\.getenv|XDG|HOME' Configs/.local/share/hypr/lua/hyde/path.lua
echo
echo "== shell quoting probe without repository code =="
python3 - <<'PY'
def current(path):
return f"[ -d '{path}' ] && echo 1 || echo 0"
examples = [
"/home/alice",
"/home/alice's project",
"/home/alice; rm -rf /",
"/home/alice'\"; echo PWNED; #",
]
for path in examples:
cmd = current(path)
print(repr(path))
print(cmd)
print()
PYRepository: HyDE-Project/HyDE Length of output: 7346 Shell-quote paths before probing them.
Proposed fix+local function shell_quote(value)
+ return "'" .. value:gsub("'", "'\\''") .. "'"
+end
+
local function is_directory(path)
- local pipe = io.popen("[ -d '" .. path .. "' ] && echo 1 || echo 0")
+ local pipe = io.popen("[ -d " .. shell_quote(path) .. " ] && echo 1 || echo 0")📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||||||
| if not pipe then | ||||||||||||||||||
| return false | ||||||||||||||||||
| end | ||||||||||||||||||
|
|
||||||||||||||||||
| local answer = pipe:read("*l") | ||||||||||||||||||
| pipe:close() | ||||||||||||||||||
|
|
||||||||||||||||||
| return answer == "1" | ||||||||||||||||||
| end | ||||||||||||||||||
|
|
||||||||||||||||||
| -- HyDE share directory: check user, then system | ||||||||||||||||||
| local share_paths = { | ||||||||||||||||||
| (os.getenv("XDG_DATA_HOME") or (os.getenv("HOME") .. "/.local/share")) .. "", | ||||||||||||||||||
| "/usr/local/share", | ||||||||||||||||||
| "/usr/share", | ||||||||||||||||||
| } | ||||||||||||||||||
| for _, p in ipairs(share_paths) do | ||||||||||||||||||
| local test = io.popen("[ -d '" .. p .. "' ] && echo 1 || echo 0"):read("*l") | ||||||||||||||||||
| if test == "1" then | ||||||||||||||||||
| P.share = p | ||||||||||||||||||
| break | ||||||||||||||||||
| --- Returns the first candidate that exists, preferring the user's own. | ||||||||||||||||||
| --- | ||||||||||||||||||
| --- @param user_path string|nil User candidate, nil when it cannot be resolved. | ||||||||||||||||||
| --- @param system_paths table Ordered system candidates to fall back on. | ||||||||||||||||||
| --- @return string|nil path The first existing directory, or nil when none do. | ||||||||||||||||||
| local function first_directory(user_path, system_paths) | ||||||||||||||||||
| if user_path and is_directory(user_path) then | ||||||||||||||||||
| return user_path | ||||||||||||||||||
| end | ||||||||||||||||||
|
|
||||||||||||||||||
| for _, path in ipairs(system_paths) do | ||||||||||||||||||
| if is_directory(path) then | ||||||||||||||||||
| return path | ||||||||||||||||||
| end | ||||||||||||||||||
| end | ||||||||||||||||||
| end | ||||||||||||||||||
|
|
||||||||||||||||||
| --- @field lib string|nil Directory holding the HyDE Lua and shell libraries. | ||||||||||||||||||
| P.lib = first_directory(HOME and HOME .. "/.local/lib", {"/usr/local/lib", "/usr/lib"}) | ||||||||||||||||||
|
|
||||||||||||||||||
| --- @field share string|nil Directory holding the shipped HyDE data files. | ||||||||||||||||||
| P.share = first_directory(P.data, {"/usr/local/share", "/usr/share"}) | ||||||||||||||||||
|
|
||||||||||||||||||
| -- Make globally accessible | ||||||||||||||||||
| _G.hyde = _G.hyde or {} | ||||||||||||||||||
| _G.hyde.path = P | ||||||||||||||||||
|
|
||||||||||||||||||
|
|
||||||||||||||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,74 @@ | ||
| #!/usr/bin/env sh | ||
| # The Hyprland config resolves its paths through hyde.path, which runs before | ||
| # anything else in the session. An environment it cannot cope with takes the | ||
| # whole config down, so the resolver has to survive a missing or empty variable | ||
| # rather than error on a nil concatenation. | ||
|
|
||
| # shellcheck source=tests/lib/common.sh | ||
| . "$(dirname -- "$0")/lib/common.sh" | ||
|
|
||
| if ! command -v lua >/dev/null 2>&1; then | ||
| skip "lua is not installed" | ||
| finish | ||
| fi | ||
|
|
||
| path_module="$REPO_ROOT/Configs/.local/share/hypr/lua/hyde/path.lua" | ||
| [ -f "$path_module" ] || { | ||
| fail "hyde/path.lua is missing" | ||
| finish | ||
| } | ||
|
|
||
| work_dir=$(mktemp -d) | ||
| trap 'rm -rf "$work_dir"' EXIT | ||
| mkdir -p "$work_dir/home/.config" | ||
|
|
||
| # Prints the resolved value of one field, or "error" when loading blew up. | ||
| resolve() { | ||
| lua -e " | ||
| local ok, err = pcall(dofile, [[$path_module]]) | ||
| if not ok then | ||
| io.write('error: ', tostring(err)) | ||
| else | ||
| io.write(tostring(hyde.path.$1)) | ||
| end | ||
| " 2>&1 | ||
| } | ||
|
|
||
| config=$(HOME="$work_dir/home" XDG_CONFIG_HOME='' resolve config) | ||
| [ "$config" = "$work_dir/home/.config" ] || | ||
| fail "an empty XDG_CONFIG_HOME did not fall back to HOME: $config" | ||
|
|
||
| config=$(HOME="$work_dir/home" XDG_CONFIG_HOME="$work_dir/elsewhere" resolve config) | ||
| [ "$config" = "$work_dir/elsewhere" ] || | ||
| fail "a set XDG_CONFIG_HOME was not honoured: $config" | ||
|
|
||
| # A session without HOME cannot resolve the fallback, but it must not error: | ||
| # an unresolved path is skippable, a raised error is not. | ||
| config=$(env -u HOME -u XDG_CONFIG_HOME lua -e " | ||
| local ok, err = pcall(dofile, [[$path_module]]) | ||
| if not ok then | ||
| io.write('error: ', tostring(err)) | ||
| else | ||
| io.write(tostring(hyde.path.config)) | ||
| end | ||
| " 2>&1) | ||
| case $config in | ||
| error*) fail "an absent HOME raised instead of leaving the path unresolved: $config" ;; | ||
| nil) ;; | ||
| *) fail "an absent HOME resolved a path out of nowhere: $config" ;; | ||
| esac | ||
|
|
||
| # The consumer has to be prepared for that, otherwise the guard above buys | ||
| # nothing: dynamic.lua must not concatenate the config path unconditionally. | ||
| dynamic="$REPO_ROOT/Configs/.local/share/hypr/lua/dynamic.lua" | ||
| grep -q 'os.getenv("XDG_CONFIG_HOME") \.\.' "$dynamic" && | ||
| fail "dynamic.lua still builds the config path by concatenating os.getenv" | ||
|
|
||
| # Every probe of an optional file has to close what it opened; a bare | ||
| # `if io.open(path) then` leaks a handle on every config reload. | ||
| grep -qE '^[[:space:]]*if io\.open\(' "$dynamic" && | ||
| fail "dynamic.lua tests a file with io.open without closing the handle" | ||
|
|
||
| printf ' %d path field(s) checked\n' 3 | ||
|
|
||
| finish |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win
🧩 Analysis chain
🏁 Script executed:
Repository: HyDE-Project/HyDE
Length of output: 602
🏁 Script executed:
Repository: HyDE-Project/HyDE
Length of output: 29570
🏁 Script executed:
Repository: HyDE-Project/HyDE
Length of output: 4571
🏁 Script executed:
Repository: HyDE-Project/HyDE
Length of output: 313
🏁 Script executed:
Repository: HyDE-Project/HyDE
Length of output: 313
Normalize an empty runtime directory to
nil.P.runtime = os.getenv("XDG_RUNTIME_DIR")leaves an emptyXDG_RUNTIME_DIRas"", a truthy Lua value that violates the module’s “unresolved paths arenil” contract for runtime state. Treat an empty runtime as unset, and add a regression case for this input.🤖 Prompt for AI Agents