From 25a44660a46cd5e368a31e5c7fdaf85638c7cb58 Mon Sep 17 00:00:00 2001 From: Sky Johnson Date: Thu, 17 Jul 2025 21:42:11 -0500 Subject: [PATCH] fs module improvements --- modules/fs/fs.lua | 185 +++++++++++++++++++++++++++++++--------------- 1 file changed, 124 insertions(+), 61 deletions(-) diff --git a/modules/fs/fs.lua b/modules/fs/fs.lua index 471e4df..375d696 100644 --- a/modules/fs/fs.lua +++ b/modules/fs/fs.lua @@ -3,6 +3,20 @@ local fs = {} local is_windows = package.config:sub(1,1) == '\\' local path_sep = is_windows and '\\' or '/' +-- ====================================================================== +-- UTILITY FUNCTIONS +-- ====================================================================== + +local function shell_escape(str) + if is_windows then + -- Windows: escape quotes and wrap in quotes + return '"' .. str:gsub('"', '""') .. '"' + else + -- Unix: escape shell metacharacters + return "'" .. str:gsub("'", "'\"'\"'") .. "'" + end +end + -- ====================================================================== -- FILE OPERATIONS -- ====================================================================== @@ -28,21 +42,18 @@ end function fs.is_dir(path) if not fs.exists(path) then return false end - -- Check if we can list directory (platform specific) + local cmd if is_windows then - local handle = io.popen('dir "' .. path .. '" 2>nul') - if handle then - local result = handle:read("*a") - handle:close() - return result and result:match("Directory of") ~= nil - end + cmd = 'dir ' .. shell_escape(path) .. ' >nul 2>&1 && echo dir' else - local handle = io.popen('test -d "' .. path .. '" 2>/dev/null && echo "dir"') - if handle then - local result = handle:read("*l") - handle:close() - return result == "dir" - end + cmd = 'test -d ' .. shell_escape(path) .. ' && echo dir' + end + + local handle = io.popen(cmd) + if handle then + local result = handle:read("*l") + handle:close() + return result == "dir" end return false @@ -69,7 +80,6 @@ function fs.read(path) end function fs.write(path, content) - -- Check for obviously invalid paths if path == "" or path:find("\0") then error("Failed to write file '" .. path .. "': invalid path") end @@ -135,7 +145,6 @@ function fs.copy(src, dst) end function fs.move(src, dst) - -- Try native rename first local success = os.rename(src, dst) if success then return true end @@ -154,20 +163,20 @@ function fs.remove(path) end function fs.mtime(path) - -- Use os.execute with stat command + local cmd if is_windows then - local cmd = 'for %i in ("' .. path .. '") do @echo %~ti' + cmd = 'forfiles /p . /m ' .. shell_escape(fs.basename(path)) .. ' /c "cmd /c echo @fdate @ftime" 2>nul' local handle = io.popen(cmd) if handle then local result = handle:read("*line") handle:close() if result then - -- Parse Windows timestamp (basic implementation) - return os.time() -- Fallback to current time + -- Basic Windows timestamp parsing - fallback to file existence check + return fs.exists(path) and os.time() or nil end end else - local cmd = 'stat -c %Y "' .. path .. '" 2>/dev/null' + cmd = 'stat -c %Y ' .. shell_escape(path) .. ' 2>/dev/null' local handle = io.popen(cmd) if handle then local result = handle:read("*line") @@ -183,7 +192,7 @@ end function fs.touch(path) if fs.exists(path) then - -- Update timestamp - basic implementation + -- Update timestamp local content = fs.read(path) fs.write(path, content) else @@ -215,12 +224,14 @@ end function fs.mkdir(path) local cmd if is_windows then - cmd = 'mkdir "' .. path .. '" 2>nul' + cmd = 'mkdir ' .. shell_escape(path) .. ' >nul 2>&1' else - cmd = 'mkdir -p "' .. path .. '"' + cmd = 'mkdir -p ' .. shell_escape(path) end - local success = os.execute(cmd) + local result = os.execute(cmd) + -- Handle different Lua version return values + local success = (result == 0) or (result == true) if not success then error("Failed to create directory '" .. path .. "'") end @@ -230,12 +241,13 @@ end function fs.rmdir(path) local cmd if is_windows then - cmd = 'rmdir /s /q "' .. path .. '"' + cmd = 'rmdir /s /q ' .. shell_escape(path) .. ' >nul 2>&1' else - cmd = 'rm -rf "' .. path .. '"' + cmd = 'rm -rf ' .. shell_escape(path) end - local success = os.execute(cmd) + local result = os.execute(cmd) + local success = (result == 0) or (result == true) if not success then error("Failed to remove directory '" .. path .. "'") end @@ -255,14 +267,14 @@ function fs.list(path) local cmd if is_windows then - cmd = 'dir /b "' .. path .. '" 2>nul' + cmd = 'dir /b ' .. shell_escape(path) .. ' 2>nul' else - cmd = 'ls -1 "' .. path .. '" 2>/dev/null' + cmd = 'ls -1 ' .. shell_escape(path) .. ' 2>/dev/null' end local handle = io.popen(cmd) if not handle then - return nil + error("Failed to list directory '" .. path .. "': command failed") end for name in handle:lines() do @@ -311,19 +323,21 @@ function fs.list_names(path) end -- ====================================================================== --- PATH OPERATIONS (Optimized pure Lua) +-- PATH OPERATIONS -- ====================================================================== function fs.join(...) local parts = {...} if #parts == 0 then return "" end - if #parts == 1 then return parts[1] end + if #parts == 1 then return parts[1] or "" end - local result = parts[1] + local result = parts[1] or "" for i = 2, #parts do local part = parts[i] - if part ~= "" then - if result:sub(-1) == path_sep or result == "" then + if part and part ~= "" then + if result == "" then + result = part + elseif result:sub(-1) == path_sep then result = result .. part else result = result .. path_sep .. part @@ -335,14 +349,25 @@ function fs.join(...) end function fs.dirname(path) + if path == "" then return "." end + + -- Remove trailing separators + path = path:gsub("[/\\]+$", "") + local pos = path:find("[/\\][^/\\]*$") if pos then - return path:sub(1, pos - 1) + local dir = path:sub(1, pos - 1) + return dir ~= "" and dir or path_sep end return "." end function fs.basename(path) + if path == "" then return "" end + + -- Remove trailing separators + path = path:gsub("[/\\]+$", "") + return path:match("[^/\\]*$") or "" end @@ -353,40 +378,69 @@ function fs.ext(path) end function fs.abs(path) + if path == "" then path = "." end + if is_windows then if path:match("^[A-Za-z]:") or path:match("^\\\\") then - return path + return fs.clean(path) end else if path:sub(1, 1) == "/" then - return path + return fs.clean(path) end end local cwd = fs.getcwd() - return fs.join(cwd, path) + return fs.clean(fs.join(cwd, path)) end function fs.clean(path) + if path == "" then return "." end + -- Normalize path separators path = path:gsub("[/\\]+", path_sep) - -- Handle . and .. components + -- Track if path was absolute + local is_absolute = false + if is_windows then + is_absolute = path:match("^[A-Za-z]:") or path:match("^\\\\") + else + is_absolute = path:sub(1, 1) == "/" + end + + -- Split into components local parts = {} - for part in path:gmatch("[^" .. path_sep .. "]+") do - if part == ".." and #parts > 0 and parts[#parts] ~= ".." then - parts[#parts] = nil - elseif part ~= "." then + for part in path:gmatch("[^" .. path_sep:gsub("\\", "\\\\") .. "]+") do + if part == ".." then + if #parts > 0 and parts[#parts] ~= ".." then + if not is_absolute or #parts > 1 then + parts[#parts] = nil + end + elseif not is_absolute then + parts[#parts + 1] = ".." + end + elseif part ~= "." and part ~= "" then parts[#parts + 1] = part end end local result = table.concat(parts, path_sep) - if path:sub(1, 1) == path_sep then - result = path_sep .. result + + if is_absolute then + if is_windows and path:match("^[A-Za-z]:") then + -- Windows drive letter + local drive = path:match("^[A-Za-z]:") + result = drive .. path_sep .. result + elseif is_windows and path:match("^\\\\") then + -- UNC path + result = "\\\\" .. result + else + -- Unix absolute + result = path_sep .. result + end end - return result ~= "" and result or "." + return result ~= "" and result or (is_absolute and path_sep or ".") end function fs.split(path) @@ -446,7 +500,6 @@ function fs.tempfile(prefix) temp_path = fs.join("/tmp", temp_name) end - -- Create the file fs.write(temp_path, "") return temp_path end @@ -472,9 +525,14 @@ end -- ====================================================================== function fs.glob(pattern) + -- Simple validation to prevent obvious shell injection + if pattern:find("[;&|`$(){}]") then + return {} + end + local cmd if is_windows then - cmd = 'for %f in ("' .. pattern .. '") do @echo %f' + cmd = 'for %f in (' .. pattern .. ') do @echo %f 2>nul' else cmd = 'ls -1d ' .. pattern .. ' 2>/dev/null' end @@ -500,9 +558,11 @@ function fs.walk(root) files[#files + 1] = path if fs.is_dir(path) then - local entries = fs.list(path) - for _, entry in ipairs(entries) do - walk_recursive(fs.join(path, entry.name)) + local success, entries = pcall(fs.list, path) + if success then + for _, entry in ipairs(entries) do + walk_recursive(fs.join(path, entry.name)) + end end end end @@ -512,7 +572,7 @@ function fs.walk(root) end -- ====================================================================== --- UTILITY FUNCTIONS (using optimized implementations) +-- UTILITY FUNCTIONS -- ====================================================================== function fs.extension(path) @@ -605,7 +665,9 @@ function fs.find(root, pattern, recursive) local results = {} local function search(dir) - local entries = fs.list(dir) + local success, entries = pcall(fs.list, dir) + if not success then return end + for _, entry in ipairs(entries) do local full_path = fs.join(dir, entry.name) @@ -621,7 +683,6 @@ function fs.find(root, pattern, recursive) return results end --- Get directory tree as nested table function fs.tree(root, max_depth) max_depth = max_depth or 10 @@ -638,12 +699,14 @@ function fs.tree(root, max_depth) if node.is_dir then node.children = {} - local entries = fs.list(path) - for _, entry in ipairs(entries) do - local child_path = fs.join(path, entry.name) - local child = build_tree(child_path, depth + 1) - if child then - node.children[#node.children + 1] = child + local success, entries = pcall(fs.list, path) + if success then + for _, entry in ipairs(entries) do + local child_path = fs.join(path, entry.name) + local child = build_tree(child_path, depth + 1) + if child then + node.children[#node.children + 1] = child + end end end else