Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
- Hyprland: dropped the legacy hyprlang dot, the files it deployed no longer exist

### Fixed
- Hyprland: a session started without `XDG_CONFIG_HOME`, such as one launched from a TTY, no longer dies with a Lua error before the first window; the unset variable is treated as unset instead of being pasted into a path
- Waybar: the theme module and the HyDE menu call `theme.switch` again; `themeswitch` was removed and the calls failed outright
- Dolphin: the "Set As Wallpaper" service menu switches the theme again
- Fish: `HYPRLAND_CONFIG` points at the deployed `hypr/hyde.lua` instead of a config the Lua release deleted, so a session started outside uwsm no longer comes up without a config
Expand Down
16 changes: 12 additions & 4 deletions Configs/.local/share/hypr/lua/dynamic.lua
Original file line number Diff line number Diff line change
Expand Up @@ -58,10 +58,18 @@ end
local state_ui = check_require("lua_state.ui") or {}
hyde.config({ui = state_ui.ui or state_ui, wallbash = state_ui.wallbash or {}}, {skip_empty = true}) -- Merge the config safely and ignore blank strings from state UI

-- Loads user config if exists
local user_config_path = os.getenv("XDG_CONFIG_HOME") .. "/hyde/config.toml"
if io.open(user_config_path) then
hyde.config.load_toml(user_config_path)
-- Loads the user config if there is one. The directory comes from hyde.path,
-- which leaves it nil rather than building a path out of an unset variable, so
-- a session without XDG_CONFIG_HOME skips the optional file instead of dying
-- on a nil concatenation. The probe closes the handle it opens: this runs on
-- every reload, and a leaked one accumulates for the life of the session.
local user_config_path = hyde.path.config and (hyde.path.config .. "/hyde/config.toml")
if user_config_path then
local user_config = io.open(user_config_path)
if user_config then
user_config:close()
hyde.config.load_toml(user_config_path)
end
end

check_require("lua_state.animations")
Expand Down
113 changes: 84 additions & 29 deletions Configs/.local/share/hypr/lua/hyde/path.lua
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")
Comment on lines +51 to 54

Copy link
Copy Markdown
Contributor

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:

#!/bin/sh
# Statically verify that empty runtime values are normalized and covered.
rg -n -C 2 'P\.runtime|XDG_RUNTIME_DIR' \
  Configs/.local/share/hypr/lua/hyde/path.lua tests/test_paths.sh

Repository: HyDE-Project/HyDE

Length of output: 602


🏁 Script executed:

#!/bin/sh
set -eu
echo "== file excerpt =="
sed -n '1,120p' Configs/.local/share/hyperpr/lua/hyde/path.lua 2>/dev/null || sed -n '1,140p' Configs/.local/share/hypr/lua/hyde/path.lua

echo "== consumers of P.runtime =="
rg -n 'P\.runtime|runtime' Configs/.local/share/hypr/lua/hyde -S || true
rg -n 'P\.runtime|runtime' Configs tests -S || true

echo "== tests around path/env =="
rg -n -C 3 'XDG_RUNTIME_DIR|runtime|path' tests Configs/.local/share/hypr/lua/hyde -S || true

echo "== Lua truthiness probe =="
lua5.1 - <<'LUA' 2>/dev/null || lua - <<'LUA' 2>/dev/null || true
local runtime = ""
print("runtime=" .. runtime)
print("runtime truthy=" .. tostring(runtime and "yes" or "no"))
LUA

Repository: HyDE-Project/HyDE

Length of output: 29570


🏁 Script executed:

#!/bin/sh
set -eu
echo "== file excerpt =="
sed -n '1,160p' Configs/.local/share/hypr/lua/hyde/path.lua

echo "== consumers of P.runtime =="
rg -n 'P\.runtime|runtime' Configs/.local/share/hypr/lua/hyde -S || true
rg -n 'P\.runtime|runtime' Configs tests -S || true

echo "== Lua truthiness probe =="
lua5.1 - <<'LUA' 2>/dev/null || lua - <<'LUA' 2>/dev/null || true
local runtime = ""
print("runtime=" .. runtime)
print("runtime truthy=" .. tostring(runtime and "yes" or "no"))
LUA

Repository: HyDE-Project/HyDE

Length of output: 4571


🏁 Script executed:

#!/bin/sh
set -eu
echo "== hyde/runtime-related occurrences across repo =="
rg -n -C 3 'hyde\.path\.runtime|XDG_RUNTIME_DIR|P\.runtime|runtime' Configs tests -S \
  -g '!test_screenshot_wrapper.sh' || true

echo "== path module direct runtime tests =="
python3 - <<'PY'
from pathlib import Path
p = Path("tests/test_paths.sh")
text = p.read_text() if p.exists() else ""
for line in text.splitlines():
    if "XDG_RUNTIME_DIR" in line or 'P.runtime' in line:
        print(line)
PY

echo "== Lua empty-string truthiness probe =="
lua - <<'LUA' 2>/dev/null || true
local runtime = ""
print("runtime=" .. runtime)
print("runtime truthy=" .. tostring(runtime and "yes" or "no"))
LUA

Repository: HyDE-Project/HyDE

Length of output: 313


🏁 Script executed:

#!/bin/sh
set -eu
echo "== hyde/runtime-related occurrences across repo =="
rg -n -C 3 'hyde\.path\.runtime|XDG_RUNTIME_DIR|P\.runtime|runtime' Configs tests -S \
  -g '!test_screenshot_wrapper.sh' || true

echo "== path module direct runtime tests =="
python3 <<'PY'
from pathlib import Path
p = Path("tests/test_paths.sh")
text = p.read_text() if p.exists() else ""
for line in text.splitlines():
    if "XDG_RUNTIME_DIR" in line or 'P.runtime' in line:
        print(line)
PY

echo "== Lua empty-string truthiness probe =="
lua - <<'LUA' 2>/dev/null || true
local runtime = ""
print("runtime=" .. runtime)
print("runtime truthy=" .. tostring(runtime and "yes" or "no"))
LUA

Repository: HyDE-Project/HyDE

Length of output: 313


Normalize an empty runtime directory to nil.

P.runtime = os.getenv("XDG_RUNTIME_DIR") leaves an empty XDG_RUNTIME_DIR as "", a truthy Lua value that violates the module’s “unresolved paths are nil” contract for runtime state. Treat an empty runtime as unset, and add a regression case for this input.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@Configs/.local/share/hypr/lua/hyde/path.lua` around lines 51 - 54, Update the
P.runtime initialization in path.lua to convert an empty XDG_RUNTIME_DIR value
to nil while preserving non-empty values and unset-variable behavior. Add a
regression test covering an explicitly empty XDG_RUNTIME_DIR and verify the
unresolved runtime path remains nil.


-- 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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The 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.lua

Repository: 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()
PY

Repository: HyDE-Project/HyDE

Length of output: 7346


Shell-quote paths before probing them.

is_directory() interpolates HOME/XDG values into a shell command without escaping. A path containing ' breaks quoting; if HOME or an XDG directory is set maliciously, it can inject shell syntax while Hyprland reads its configuration.

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

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
local function is_directory(path)
local pipe = io.popen("[ -d '" .. path .. "' ] && echo 1 || echo 0")
local function shell_quote(value)
return "'" .. value:gsub("'", "'\\''") .. "'"
end
local function is_directory(path)
local pipe = io.popen("[ -d " .. shell_quote(path) .. " ] && echo 1 || echo 0")
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@Configs/.local/share/hypr/lua/hyde/path.lua` around lines 64 - 65, Update
is_directory to safely shell-quote the path before interpolating it into the
io.popen test command, preserving correct directory detection for paths
containing apostrophes and preventing injected shell syntax from executing.

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

Expand Down
74 changes: 74 additions & 0 deletions tests/test_paths.sh
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
Loading