--- Native Lua implementation of filesystem and platform abstractions, -- using LuaFileSystem, LZLib, MD5 and LuaCurl. module("luarocks.fs.lua", package.seeall) local fs = require("luarocks.fs") local cfg = require("luarocks.cfg") local dir = require("luarocks.dir") local util = require("luarocks.util") local path = require("luarocks.path") local socket_ok, zip_ok, unzip_ok, lfs_ok, md5_ok, posix_ok, _ local http, ftp, lrzip, luazip, lfs, md5, posix if cfg.fs_use_modules then socket_ok, http = pcall(require, "socket.http") _, ftp = pcall(require, "socket.ftp") zip_ok, lrzip = pcall(require, "luarocks.tools.zip") unzip_ok, luazip = pcall(require, "zip"); _G.zip = nil lfs_ok, lfs = pcall(require, "lfs") md5_ok, md5 = pcall(require, "md5") posix_ok, posix = pcall(require, "posix") end local patch = require("luarocks.tools.patch") local dir_stack = {} math.randomseed(os.time()) dir_separator = "/" --- Quote argument for shell processing. -- Adds single quotes and escapes. -- @param arg string: Unquoted argument. -- @return string: Quoted argument. function Q(arg) assert(type(arg) == "string") -- FIXME Unix-specific return "'" .. arg:gsub("'", "'\\''") .. "'" end --- Test is file/dir is writable. -- Warning: testing if a file/dir is writable does not guarantee -- that it will remain writable and therefore it is no replacement -- for checking the result of subsequent operations. -- @param file string: filename to test -- @return boolean: true if file exists, false otherwise. function is_writable(file) assert(file) file = dir.normalize(file) local result if fs.is_dir(file) then local file2 = dir.path(file, '.tmpluarockstestwritable') local fh = io.open(file2, 'wb') result = fh ~= nil if fh then fh:close() end os.remove(file2) else local fh = io.open(file, 'r+b') result = fh ~= nil if fh then fh:close() end end return result end --- Create a temporary directory. -- @param name string: name pattern to use for avoiding conflicts -- when creating temporary directory. -- @return string or (nil, string): name of temporary directory or (nil, error message) on failure. function make_temp_dir(name) assert(type(name) == "string") name = dir.normalize(name) local temp_dir = (os.getenv("TMP") or "/tmp") .. "/luarocks_" .. name:gsub(dir.separator, "_") .. "-" .. tostring(math.floor(math.random() * 10000)) local ok, err = fs.make_dir(temp_dir) if ok then return temp_dir else return nil, err end end local function quote_args(command, ...) local out = { command } for _, arg in ipairs({...}) do assert(type(arg) == "string") out[#out+1] = fs.Q(arg) end return table.concat(out, " ") end --- Run the given command, quoting its arguments. -- The command is executed in the current directory in the dir stack. -- @param command string: The command to be executed. No quoting/escaping -- is applied. -- @param ... Strings containing additional arguments, which are quoted. -- @return boolean: true if command succeeds (status code 0), false -- otherwise. function execute(command, ...) assert(type(command) == "string") return fs.execute_string(quote_args(command, ...)) end --- Run the given command, quoting its arguments, silencing its output. -- The command is executed in the current directory in the dir stack. -- Silencing is omitted if 'verbose' mode is enabled. -- @param command string: The command to be executed. No quoting/escaping -- is applied. -- @param ... Strings containing additional arguments, which will be quoted. -- @return boolean: true if command succeeds (status code 0), false -- otherwise. function execute_quiet(command, ...) assert(type(command) == "string") if cfg.verbose then -- omit silencing output return fs.execute_string(quote_args(command, ...)) else return fs.execute_string(fs.quiet(quote_args(command, ...))) end end --- Check the MD5 checksum for a file. -- @param file string: The file to be checked. -- @param md5sum string: The string with the expected MD5 checksum. -- @return boolean: true if the MD5 checksum for 'file' equals 'md5sum', false + msg if not -- or if it could not perform the check for any reason. function check_md5(file, md5sum) file = dir.normalize(file) local computed, msg = fs.get_md5(file) if not computed then return false, msg end if computed:match("^"..md5sum) then return true else return false, "Mismatch MD5 hash for file "..file end end --------------------------------------------------------------------- -- LuaFileSystem functions --------------------------------------------------------------------- if lfs_ok then --- Run the given command. -- The command is executed in the current directory in the dir stack. -- @param cmd string: No quoting/escaping is applied to the command. -- @return boolean: true if command succeeds (status code 0), false -- otherwise. function execute_string(cmd) local code = os.execute(cmd) return (code == 0 or code == true) end --- Obtain current directory. -- Uses the module's internal dir stack. -- @return string: the absolute pathname of the current directory. function current_dir() return lfs.currentdir() end --- Change the current directory. -- Uses the module's internal dir stack. This does not have exact -- semantics of chdir, as it does not handle errors the same way, -- but works well for our purposes for now. -- @param d string: The directory to switch to. function change_dir(d) table.insert(dir_stack, lfs.currentdir()) d = dir.normalize(d) return lfs.chdir(d) end --- Change directory to root. -- Allows leaving a directory (e.g. for deleting it) in -- a crossplatform way. function change_dir_to_root() table.insert(dir_stack, lfs.currentdir()) lfs.chdir("/") -- works on Windows too end --- Change working directory to the previous in the dir stack. -- @return true if a pop ocurred, false if the stack was empty. function pop_dir() local d = table.remove(dir_stack) if d then lfs.chdir(d) return true else return false end end --- Create a directory if it does not already exist. -- If any of the higher levels in the path name do not exist -- too, they are created as well. -- @param directory string: pathname of directory to create. -- @return boolean or (boolean, string): true on success or (false, error message) on failure. function make_dir(directory) assert(type(directory) == "string") directory = dir.normalize(directory) local path = nil if directory:sub(2, 2) == ":" then path = directory:sub(1, 2) directory = directory:sub(4) else if directory:match("^/") then path = "" end end for d in directory:gmatch("([^"..dir.separator.."]+)"..dir.separator.."*") do path = path and path .. dir.separator .. d or d local mode = lfs.attributes(path, "mode") if not mode then local ok, err = lfs.mkdir(path) if not ok then return false, err end elseif mode ~= "directory" then return false, path.." is not a directory" end end return true end --- Remove a directory if it is empty. -- Does not return errors (for example, if directory is not empty or -- if already does not exist) -- @param d string: pathname of directory to remove. function remove_dir_if_empty(d) assert(d) d = dir.normalize(d) lfs.rmdir(d) end --- Remove a directory if it is empty. -- Does not return errors (for example, if directory is not empty or -- if already does not exist) -- @param d string: pathname of directory to remove. function remove_dir_tree_if_empty(d) assert(d) d = dir.normalize(d) for i=1,10 do lfs.rmdir(d) d = dir.dir_name(d) end end --- Copy a file. -- @param src string: Pathname of source -- @param dest string: Pathname of destination -- @param perms string or nil: Permissions for destination file, -- or nil to use the source filename permissions -- @return boolean or (boolean, string): true on success, false on failure, -- plus an error message. function copy(src, dest, perms) assert(src and dest) src = dir.normalize(src) dest = dir.normalize(dest) local destmode = lfs.attributes(dest, "mode") if destmode == "directory" then dest = dir.path(dest, dir.base_name(src)) end if not perms then perms = fs.get_permissions(src) end local src_h, err = io.open(src, "rb") if not src_h then return nil, err end local dest_h, err = io.open(dest, "w+b") if not dest_h then src_h:close() return nil, err end while true do local block = src_h:read(8192) if not block then break end dest_h:write(block) end src_h:close() dest_h:close() fs.chmod(dest, perms) return true end --- Implementation function for recursive copy of directory contents. -- Assumes paths are normalized. -- @param src string: Pathname of source -- @param dest string: Pathname of destination -- @return boolean or (boolean, string): true on success, false on failure local function recursive_copy(src, dest) local srcmode = lfs.attributes(src, "mode") if srcmode == "file" then local ok = fs.copy(src, dest) if not ok then return false end elseif srcmode == "directory" then local subdir = dir.path(dest, dir.base_name(src)) local ok, err = fs.make_dir(subdir) if not ok then return nil, err end for file in lfs.dir(src) do if file ~= "." and file ~= ".." then local ok = recursive_copy(dir.path(src, file), subdir) if not ok then return false end end end end return true end --- Recursively copy the contents of a directory. -- @param src string: Pathname of source -- @param dest string: Pathname of destination -- @return boolean or (boolean, string): true on success, false on failure, -- plus an error message. function copy_contents(src, dest) assert(src and dest) src = dir.normalize(src) dest = dir.normalize(dest) assert(lfs.attributes(src, "mode") == "directory") for file in lfs.dir(src) do if file ~= "." and file ~= ".." then local ok = recursive_copy(dir.path(src, file), dest) if not ok then return false, "Failed copying "..src.." to "..dest end end end return true end --- Implementation function for recursive removal of directories. -- Assumes paths are normalized. -- @param name string: Pathname of file -- @return boolean or (boolean, string): true on success, -- or nil and an error message on failure. local function recursive_delete(name) local ok = os.remove(name) if ok then return true end local pok, ok, err = pcall(function() for file in lfs.dir(name) do if file ~= "." and file ~= ".." then local ok, err = recursive_delete(dir.path(name, file)) if not ok then return nil, err end end end local ok, err = lfs.rmdir(name) return ok, (not ok) and err end) if pok then return ok, err else return pok, ok end end --- Delete a file or a directory and all its contents. -- @param name string: Pathname of source -- @return nil function delete(name) name = dir.normalize(name) recursive_delete(name) end --- List the contents of a directory. -- @param at string or nil: directory to list (will be the current -- directory if none is given). -- @return table: an array of strings with the filenames representing -- the contents of a directory. function list_dir(at) assert(type(at) == "string" or not at) if not at then at = fs.current_dir() end at = dir.normalize(at) if not fs.is_dir(at) then return {} end local result = {} for file in lfs.dir(at) do if file ~= "." and file ~= ".." then table.insert(result, file) end end return result end --- Implementation function for recursive find. -- Assumes paths are normalized. -- @param cwd string: Current working directory in recursion. -- @param prefix string: Auxiliary prefix string to form pathname. -- @param result table: Array of strings where results are collected. local function recursive_find(cwd, prefix, result) for file in lfs.dir(cwd) do if file ~= "." and file ~= ".." then local item = prefix .. file table.insert(result, item) local pathname = dir.path(cwd, file) if lfs.attributes(pathname, "mode") == "directory" then recursive_find(pathname, item..dir_separator, result) end end end end --- Recursively scan the contents of a directory. -- @param at string or nil: directory to scan (will be the current -- directory if none is given). -- @return table: an array of strings with the filenames representing -- the contents of a directory. function find(at) assert(type(at) == "string" or not at) if not at then at = fs.current_dir() end at = dir.normalize(at) if not fs.is_dir(at) then return {} end local result = {} recursive_find(at, "", result) return result end --- Test for existance of a file. -- @param file string: filename to test -- @return boolean: true if file exists, false otherwise. function exists(file) assert(file) file = dir.normalize(file) return type(lfs.attributes(file)) == "table" end --- Test is pathname is a directory. -- @param file string: pathname to test -- @return boolean: true if it is a directory, false otherwise. function is_dir(file) assert(file) file = dir.normalize(file) return lfs.attributes(file, "mode") == "directory" end --- Test is pathname is a regular file. -- @param file string: pathname to test -- @return boolean: true if it is a file, false otherwise. function is_file(file) assert(file) file = dir.normalize(file) return lfs.attributes(file, "mode") == "file" end function set_time(file, time) file = dir.normalize(file) return lfs.touch(file, time) end end --------------------------------------------------------------------- -- LuaZip functions --------------------------------------------------------------------- if zip_ok then function zip(zipfile, ...) return lrzip.zip(zipfile, ...) end end if unzip_ok then --- Uncompress files from a .zip archive. -- @param zipfile string: pathname of .zip archive to be extracted. -- @return boolean: true on success, false on failure. function unzip(zipfile) local zipfile, err = luazip.open(zipfile) if not zipfile then return nil, err end local files = zipfile:files() local file = files() repeat if file.filename:sub(#file.filename) == "/" then local ok, err = fs.make_dir(dir.path(fs.current_dir(), file.filename)) if not ok then return nil, err end else local rf, err = zipfile:open(file.filename) if not rf then zipfile:close(); return nil, err end local contents = rf:read("*a") rf:close() local wf, err = io.open(dir.path(fs.current_dir(), file.filename), "wb") if not wf then zipfile:close(); return nil, err end wf:write(contents) wf:close() end file = files() until not file zipfile:close() return true end end --------------------------------------------------------------------- -- LuaSocket functions --------------------------------------------------------------------- if socket_ok then local ltn12 = require("ltn12") local luasec_ok, https = pcall(require, "ssl.https") local redirect_protocols = { http = http, https = luasec_ok and https, } local function request(url, method, http, loop_control) local result = {} local proxy = cfg.proxy if type(proxy) ~= "string" then proxy = nil end -- LuaSocket's http.request crashes when given URLs missing the scheme part. if proxy and not proxy:find("://") then proxy = "http://" .. proxy end if cfg.show_downloads then io.write(method.." "..url.." ...\n") end local dots = 0 local res, status, headers, err = http.request { url = url, proxy = proxy, method = method, redirect = false, sink = ltn12.sink.table(result), step = cfg.show_downloads and function(...) io.write(".") io.flush() dots = dots + 1 if dots == 70 then io.write("\n") dots = 0 end return ltn12.pump.step(...) end, headers = { ["user-agent"] = cfg.user_agent.." via LuaSocket" }, } if cfg.show_downloads then io.write("\n") end if not res then return nil, status elseif status == 301 or status == 302 then local location = headers.location if location then local protocol, rest = dir.split_url(location) if redirect_protocols[protocol] then if not loop_control then loop_control = {} elseif loop_control[location] then return nil, "Redirection loop -- broken URL?" end loop_control[url] = true return request(location, method, redirect_protocols[protocol], loop_control) else return nil, "URL redirected to unsupported protocol - install luasec to get HTTPS support." end end return nil, err elseif status ~= 200 then return nil, err else return result, status, headers, err end end local function http_request(url, http, cached) if cached then local tsfd = io.open(cached..".timestamp", "r") if tsfd then local timestamp = tsfd:read("*a") tsfd:close() local result, status, headers, err = request(url, "HEAD", http) if status == 200 and headers["last-modified"] == timestamp then return true end end end local result, status, headers, err = request(url, "GET", http) if result then if cached and headers["last-modified"] then local tsfd = io.open(cached..".timestamp", "w") tsfd:write(headers["last-modified"]) tsfd:close() end return table.concat(result) else return nil, status end end --- Download a remote file. -- @param url string: URL to be fetched. -- @param filename string or nil: this function attempts to detect the -- resulting local filename of the remote file as the basename of the URL; -- if that is not correct (due to a redirection, for example), the local -- filename can be given explicitly as this second argument. -- @return (boolean, string): true and the filename on success, -- false and the error message on failure. function download(url, filename, cache) assert(type(url) == "string") assert(type(filename) == "string" or not filename) filename = fs.absolute_name(filename or dir.base_name(url)) local content, err if util.starts_with(url, "http:") then content, err = http_request(url, http, cache and filename) elseif util.starts_with(url, "ftp:") then content, err = ftp.get(url) elseif util.starts_with(url, "https:") then if luasec_ok then content, err = http_request(url, https, cache and filename) else err = "Unsupported protocol - install luasec to get HTTPS support." end else err = "Unsupported protocol" end if cache and content == true then return true, filename end if not content then return false, tostring(err) end local file = io.open(filename, "wb") if not file then return false end file:write(content) file:close() return true, filename end end --------------------------------------------------------------------- -- MD5 functions --------------------------------------------------------------------- if md5_ok then --- Get the MD5 checksum for a file. -- @param file string: The file to be computed. -- @return string: The MD5 checksum or nil + error function get_md5(file) file = fs.absolute_name(file) local file = io.open(file, "rb") if not file then return nil, "Failed to open file for reading: "..file end local computed = md5.sumhexa(file:read("*a")) file:close() if computed then return computed end return nil, "Failed to compute MD5 hash for file "..file end end --------------------------------------------------------------------- -- POSIX functions --------------------------------------------------------------------- if posix_ok then local octal_to_rwx = { ["0"] = "---", ["1"] = "--x", ["2"] = "-w-", ["3"] = "-wx", ["4"] = "r--", ["5"] = "r-x", ["6"] = "rw-", ["7"] = "rwx", } function chmod(file, mode) -- LuaPosix (as of 5.1.15) does not support octal notation... if mode:sub(1,1) == "0" then local new_mode = {} for c in mode:sub(2):gmatch(".") do table.insert(new_mode, octal_to_rwx[c]) end mode = table.concat(new_mode) end local err = posix.chmod(file, mode) return err == 0 end function get_permissions(file) return posix.stat(file, "mode") end end --------------------------------------------------------------------- -- Other functions --------------------------------------------------------------------- --- Apply a patch. -- @param patchname string: The filename of the patch. -- @param patchdata string or nil: The actual patch as a string. function apply_patch(patchname, patchdata) local p, all_ok = patch.read_patch(patchname, patchdata) if not all_ok then return nil, "Failed reading patch "..patchname end if p then return patch.apply_patch(p, 1) end end --- Move a file. -- @param src string: Pathname of source -- @param dest string: Pathname of destination -- @return boolean or (boolean, string): true on success, false on failure, -- plus an error message. function move(src, dest) assert(src and dest) if fs.exists(dest) and not fs.is_dir(dest) then return false, "File already exists: "..dest end local ok, err = fs.copy(src, dest) if not ok then return false, err end fs.delete(src) if fs.exists(src) then return false, "Failed move: could not delete "..src.." after copy." end return true end --- Check if user has write permissions for the command. -- Assumes the configuration variables under cfg have been previously set up. -- @param flags table: the flags table passed to run() drivers. -- @return boolean or (boolean, string): true on success, false on failure, -- plus an error message. function check_command_permissions(flags) local root_dir = path.root_dir(cfg.rocks_dir) local ok = true local err = "" for _, dir in ipairs { cfg.rocks_dir, root_dir } do if fs.exists(dir) and not fs.is_writable(dir) then ok = false err = "Your user does not have write permissions in " .. dir break end end local root_parent = dir.dir_name(root_dir) if ok and not fs.exists(root_dir) and not fs.is_writable(root_parent) then ok = false err = root_dir.." does not exist and your user does not have write permissions in " .. root_parent end if ok then return true else if flags["local"] then err = err .. " \n-- please check your permissions." else err = err .. " \n-- you may want to run as a privileged user or use your local tree with --local." end return nil, err end end