mirror of
https://github.com/LadybirdBrowser/ladybird.git
synced 2024-12-04 05:20:30 +00:00
b810f7f88a
Besides from a general check if a file's directory has write permissions, this also checks if the directory has set a sticky bit, meaning that only file owners and the directory owner can remove or move files in such directory. It's being used in /tmp for example.
632 lines
18 KiB
C++
632 lines
18 KiB
C++
/*
|
|
* Copyright (c) 2018-2022, Andreas Kling <kling@serenityos.org>
|
|
*
|
|
* SPDX-License-Identifier: BSD-2-Clause
|
|
*/
|
|
|
|
#include <AK/LexicalPath.h>
|
|
#include <AK/Platform.h>
|
|
#include <AK/ScopeGuard.h>
|
|
#include <LibCore/DirIterator.h>
|
|
#include <LibCore/File.h>
|
|
#include <LibCore/System.h>
|
|
#include <errno.h>
|
|
#include <fcntl.h>
|
|
#include <libgen.h>
|
|
#include <stdio.h>
|
|
#include <string.h>
|
|
#include <sys/stat.h>
|
|
#include <unistd.h>
|
|
#include <utime.h>
|
|
|
|
#ifdef AK_OS_SERENITY
|
|
# include <serenity.h>
|
|
#endif
|
|
|
|
// On Linux distros that use glibc `basename` is defined as a macro that expands to `__xpg_basename`, so we undefine it
|
|
#if defined(AK_OS_LINUX) && defined(basename)
|
|
# undef basename
|
|
#endif
|
|
|
|
namespace Core {
|
|
|
|
ErrorOr<NonnullRefPtr<File>> File::open(DeprecatedString filename, OpenMode mode, mode_t permissions)
|
|
{
|
|
auto file = File::construct(move(filename));
|
|
if (!file->open_impl(mode, permissions))
|
|
return Error::from_errno(file->error());
|
|
return file;
|
|
}
|
|
|
|
File::File(DeprecatedString filename, Object* parent)
|
|
: IODevice(parent)
|
|
, m_filename(move(filename))
|
|
{
|
|
}
|
|
|
|
File::~File()
|
|
{
|
|
if (m_should_close_file_descriptor == ShouldCloseFileDescriptor::Yes && mode() != OpenMode::NotOpen)
|
|
close();
|
|
}
|
|
|
|
bool File::open(int fd, OpenMode mode, ShouldCloseFileDescriptor should_close)
|
|
{
|
|
set_fd(fd);
|
|
set_mode(mode);
|
|
m_should_close_file_descriptor = should_close;
|
|
return true;
|
|
}
|
|
|
|
bool File::open(OpenMode mode)
|
|
{
|
|
return open_impl(mode, 0666);
|
|
}
|
|
|
|
bool File::open_impl(OpenMode mode, mode_t permissions)
|
|
{
|
|
VERIFY(!m_filename.is_null());
|
|
int flags = 0;
|
|
if (has_flag(mode, OpenMode::ReadOnly) && has_flag(mode, OpenMode::WriteOnly)) {
|
|
flags |= O_RDWR | O_CREAT;
|
|
} else if (has_flag(mode, OpenMode::ReadOnly)) {
|
|
flags |= O_RDONLY;
|
|
} else if (has_flag(mode, OpenMode::WriteOnly)) {
|
|
flags |= O_WRONLY | O_CREAT;
|
|
bool should_truncate = !(has_flag(mode, OpenMode::Append) || has_flag(mode, OpenMode::MustBeNew));
|
|
if (should_truncate)
|
|
flags |= O_TRUNC;
|
|
}
|
|
if (has_flag(mode, OpenMode::Append))
|
|
flags |= O_APPEND;
|
|
if (has_flag(mode, OpenMode::Truncate))
|
|
flags |= O_TRUNC;
|
|
if (has_flag(mode, OpenMode::MustBeNew))
|
|
flags |= O_EXCL;
|
|
if (!has_flag(mode, OpenMode::KeepOnExec))
|
|
flags |= O_CLOEXEC;
|
|
int fd = ::open(m_filename.characters(), flags, permissions);
|
|
if (fd < 0) {
|
|
set_error(errno);
|
|
return false;
|
|
}
|
|
|
|
set_fd(fd);
|
|
set_mode(mode);
|
|
return true;
|
|
}
|
|
|
|
int File::leak_fd()
|
|
{
|
|
m_should_close_file_descriptor = ShouldCloseFileDescriptor::No;
|
|
return fd();
|
|
}
|
|
|
|
bool File::is_device() const
|
|
{
|
|
return is_device(fd());
|
|
}
|
|
|
|
bool File::is_device(DeprecatedString const& filename)
|
|
{
|
|
struct stat st;
|
|
if (stat(filename.characters(), &st) < 0)
|
|
return false;
|
|
return S_ISBLK(st.st_mode) || S_ISCHR(st.st_mode);
|
|
}
|
|
|
|
bool File::is_device(int fd)
|
|
{
|
|
struct stat st;
|
|
if (fstat(fd, &st) < 0)
|
|
return false;
|
|
return S_ISBLK(st.st_mode) || S_ISCHR(st.st_mode);
|
|
}
|
|
|
|
bool File::is_block_device() const
|
|
{
|
|
struct stat stat;
|
|
if (fstat(fd(), &stat) < 0)
|
|
return false;
|
|
return S_ISBLK(stat.st_mode);
|
|
}
|
|
|
|
bool File::is_block_device(DeprecatedString const& filename)
|
|
{
|
|
struct stat st;
|
|
if (stat(filename.characters(), &st) < 0)
|
|
return false;
|
|
return S_ISBLK(st.st_mode);
|
|
}
|
|
|
|
bool File::is_char_device() const
|
|
{
|
|
struct stat stat;
|
|
if (fstat(fd(), &stat) < 0)
|
|
return false;
|
|
return S_ISCHR(stat.st_mode);
|
|
}
|
|
|
|
bool File::is_char_device(DeprecatedString const& filename)
|
|
{
|
|
struct stat st;
|
|
if (stat(filename.characters(), &st) < 0)
|
|
return false;
|
|
return S_ISCHR(st.st_mode);
|
|
}
|
|
|
|
bool File::is_directory() const
|
|
{
|
|
return is_directory(fd());
|
|
}
|
|
|
|
bool File::is_directory(DeprecatedString const& filename)
|
|
{
|
|
struct stat st;
|
|
if (stat(filename.characters(), &st) < 0)
|
|
return false;
|
|
return S_ISDIR(st.st_mode);
|
|
}
|
|
|
|
bool File::is_directory(int fd)
|
|
{
|
|
struct stat st;
|
|
if (fstat(fd, &st) < 0)
|
|
return false;
|
|
return S_ISDIR(st.st_mode);
|
|
}
|
|
|
|
bool File::is_link() const
|
|
{
|
|
struct stat stat;
|
|
if (fstat(fd(), &stat) < 0)
|
|
return false;
|
|
return S_ISLNK(stat.st_mode);
|
|
}
|
|
|
|
bool File::is_link(DeprecatedString const& filename)
|
|
{
|
|
struct stat st;
|
|
if (lstat(filename.characters(), &st) < 0)
|
|
return false;
|
|
return S_ISLNK(st.st_mode);
|
|
}
|
|
|
|
bool File::looks_like_shared_library() const
|
|
{
|
|
return File::looks_like_shared_library(m_filename);
|
|
}
|
|
|
|
bool File::looks_like_shared_library(DeprecatedString const& filename)
|
|
{
|
|
return filename.ends_with(".so"sv) || filename.contains(".so."sv);
|
|
}
|
|
|
|
bool File::can_delete_or_move(StringView path)
|
|
{
|
|
VERIFY(!path.is_empty());
|
|
auto directory = LexicalPath::dirname(path);
|
|
auto directory_has_write_access = !Core::System::access(directory, W_OK).is_error();
|
|
if (!directory_has_write_access)
|
|
return false;
|
|
|
|
auto stat_or_empty = [](StringView path) {
|
|
auto stat_or_error = Core::System::stat(path);
|
|
if (stat_or_error.is_error()) {
|
|
struct stat stat { };
|
|
return stat;
|
|
}
|
|
return stat_or_error.release_value();
|
|
};
|
|
|
|
auto directory_stat = stat_or_empty(directory);
|
|
bool is_directory_sticky = directory_stat.st_mode & S_ISVTX;
|
|
if (!is_directory_sticky)
|
|
return true;
|
|
|
|
// Directory is sticky, only the file owner, directory owner, and root can modify (rename, remove) it.
|
|
auto user_id = geteuid();
|
|
return user_id == 0 || directory_stat.st_uid == user_id || stat_or_empty(path).st_uid == user_id;
|
|
}
|
|
|
|
bool File::exists(StringView filename)
|
|
{
|
|
return !Core::System::stat(filename).is_error();
|
|
}
|
|
|
|
ErrorOr<size_t> File::size(DeprecatedString const& filename)
|
|
{
|
|
struct stat st;
|
|
if (stat(filename.characters(), &st) < 0)
|
|
return Error::from_errno(errno);
|
|
return st.st_size;
|
|
}
|
|
|
|
DeprecatedString File::real_path_for(DeprecatedString const& filename)
|
|
{
|
|
if (filename.is_null())
|
|
return {};
|
|
auto* path = realpath(filename.characters(), nullptr);
|
|
DeprecatedString real_path(path);
|
|
free(path);
|
|
return real_path;
|
|
}
|
|
|
|
DeprecatedString File::current_working_directory()
|
|
{
|
|
char* cwd = getcwd(nullptr, 0);
|
|
if (!cwd) {
|
|
perror("getcwd");
|
|
return {};
|
|
}
|
|
|
|
auto cwd_as_string = DeprecatedString(cwd);
|
|
free(cwd);
|
|
|
|
return cwd_as_string;
|
|
}
|
|
|
|
DeprecatedString File::absolute_path(DeprecatedString const& path)
|
|
{
|
|
if (File::exists(path))
|
|
return File::real_path_for(path);
|
|
|
|
if (path.starts_with("/"sv))
|
|
return LexicalPath::canonicalized_path(path);
|
|
|
|
auto working_directory = File::current_working_directory();
|
|
auto full_path = LexicalPath::join(working_directory, path);
|
|
|
|
return LexicalPath::canonicalized_path(full_path.string());
|
|
}
|
|
|
|
#ifdef AK_OS_SERENITY
|
|
|
|
ErrorOr<DeprecatedString> File::read_link(DeprecatedString const& link_path)
|
|
{
|
|
// First, try using a 64-byte buffer, that ought to be enough for anybody.
|
|
char small_buffer[64];
|
|
|
|
int rc = serenity_readlink(link_path.characters(), link_path.length(), small_buffer, sizeof(small_buffer));
|
|
if (rc < 0)
|
|
return Error::from_errno(errno);
|
|
|
|
size_t size = rc;
|
|
// If the call was successful, the syscall (unlike the LibC wrapper)
|
|
// returns the full size of the link. Let's see if our small buffer
|
|
// was enough to read the whole link.
|
|
if (size <= sizeof(small_buffer))
|
|
return DeprecatedString { small_buffer, size };
|
|
// Nope, but at least now we know the right size.
|
|
char* large_buffer_ptr;
|
|
auto large_buffer = StringImpl::create_uninitialized(size, large_buffer_ptr);
|
|
|
|
rc = serenity_readlink(link_path.characters(), link_path.length(), large_buffer_ptr, size);
|
|
if (rc < 0)
|
|
return Error::from_errno(errno);
|
|
|
|
size_t new_size = rc;
|
|
if (new_size == size)
|
|
return { *large_buffer };
|
|
|
|
// If we're here, the symlink has changed while we were looking at it.
|
|
// If it became shorter, our buffer is valid, we just have to trim it a bit.
|
|
if (new_size < size)
|
|
return DeprecatedString { large_buffer_ptr, new_size };
|
|
// Otherwise, here's not much we can do, unless we want to loop endlessly
|
|
// in this case. Let's leave it up to the caller whether to loop.
|
|
errno = EAGAIN;
|
|
return Error::from_errno(errno);
|
|
}
|
|
|
|
#else
|
|
|
|
// This is a sad version for other systems. It has to always make a copy of the
|
|
// link path, and to always make two syscalls to get the right size first.
|
|
ErrorOr<DeprecatedString> File::read_link(DeprecatedString const& link_path)
|
|
{
|
|
struct stat statbuf = {};
|
|
int rc = lstat(link_path.characters(), &statbuf);
|
|
if (rc < 0)
|
|
return Error::from_errno(errno);
|
|
char* buffer_ptr;
|
|
auto buffer = StringImpl::create_uninitialized(statbuf.st_size, buffer_ptr);
|
|
if (readlink(link_path.characters(), buffer_ptr, statbuf.st_size) < 0)
|
|
return Error::from_errno(errno);
|
|
// (See above.)
|
|
if (rc == statbuf.st_size)
|
|
return { *buffer };
|
|
return DeprecatedString { buffer_ptr, (size_t)rc };
|
|
}
|
|
|
|
#endif
|
|
|
|
static RefPtr<File> stdin_file;
|
|
static RefPtr<File> stdout_file;
|
|
static RefPtr<File> stderr_file;
|
|
|
|
NonnullRefPtr<File> File::standard_input()
|
|
{
|
|
if (!stdin_file) {
|
|
stdin_file = File::construct();
|
|
stdin_file->open(STDIN_FILENO, OpenMode::ReadOnly, ShouldCloseFileDescriptor::No);
|
|
}
|
|
return *stdin_file;
|
|
}
|
|
|
|
NonnullRefPtr<File> File::standard_output()
|
|
{
|
|
if (!stdout_file) {
|
|
stdout_file = File::construct();
|
|
stdout_file->open(STDOUT_FILENO, OpenMode::WriteOnly, ShouldCloseFileDescriptor::No);
|
|
}
|
|
return *stdout_file;
|
|
}
|
|
|
|
NonnullRefPtr<File> File::standard_error()
|
|
{
|
|
if (!stderr_file) {
|
|
stderr_file = File::construct();
|
|
stderr_file->open(STDERR_FILENO, OpenMode::WriteOnly, ShouldCloseFileDescriptor::No);
|
|
}
|
|
return *stderr_file;
|
|
}
|
|
|
|
static DeprecatedString get_duplicate_name(DeprecatedString const& path, int duplicate_count)
|
|
{
|
|
if (duplicate_count == 0) {
|
|
return path;
|
|
}
|
|
LexicalPath lexical_path(path);
|
|
StringBuilder duplicated_name;
|
|
duplicated_name.append('/');
|
|
auto& parts = lexical_path.parts_view();
|
|
for (size_t i = 0; i < parts.size() - 1; ++i) {
|
|
duplicated_name.appendff("{}/", parts[i]);
|
|
}
|
|
auto prev_duplicate_tag = DeprecatedString::formatted("({})", duplicate_count);
|
|
auto title = lexical_path.title();
|
|
if (title.ends_with(prev_duplicate_tag)) {
|
|
// remove the previous duplicate tag "(n)" so we can add a new tag.
|
|
title = title.substring_view(0, title.length() - prev_duplicate_tag.length());
|
|
}
|
|
duplicated_name.appendff("{} ({})", title, duplicate_count);
|
|
if (!lexical_path.extension().is_empty()) {
|
|
duplicated_name.appendff(".{}", lexical_path.extension());
|
|
}
|
|
return duplicated_name.build();
|
|
}
|
|
|
|
ErrorOr<void, File::CopyError> File::copy_file_or_directory(DeprecatedString const& dst_path, DeprecatedString const& src_path, RecursionMode recursion_mode, LinkMode link_mode, AddDuplicateFileMarker add_duplicate_file_marker, PreserveMode preserve_mode)
|
|
{
|
|
if (add_duplicate_file_marker == AddDuplicateFileMarker::Yes) {
|
|
int duplicate_count = 0;
|
|
while (access(get_duplicate_name(dst_path, duplicate_count).characters(), F_OK) == 0) {
|
|
++duplicate_count;
|
|
}
|
|
if (duplicate_count != 0) {
|
|
return copy_file_or_directory(get_duplicate_name(dst_path, duplicate_count), src_path, RecursionMode::Allowed, LinkMode::Disallowed, AddDuplicateFileMarker::Yes, preserve_mode);
|
|
}
|
|
}
|
|
|
|
auto source_or_error = File::open(src_path, OpenMode::ReadOnly);
|
|
if (source_or_error.is_error())
|
|
return CopyError { errno, false };
|
|
|
|
auto& source = *source_or_error.value();
|
|
|
|
struct stat src_stat;
|
|
if (fstat(source.fd(), &src_stat) < 0)
|
|
return CopyError { errno, false };
|
|
|
|
if (source.is_directory()) {
|
|
if (recursion_mode == RecursionMode::Disallowed)
|
|
return CopyError { errno, true };
|
|
return copy_directory(dst_path, src_path, src_stat);
|
|
}
|
|
|
|
if (link_mode == LinkMode::Allowed) {
|
|
if (link(src_path.characters(), dst_path.characters()) < 0)
|
|
return CopyError { errno, false };
|
|
|
|
return {};
|
|
}
|
|
|
|
return copy_file(dst_path, src_stat, source, preserve_mode);
|
|
}
|
|
|
|
ErrorOr<void, File::CopyError> File::copy_file(DeprecatedString const& dst_path, struct stat const& src_stat, File& source, PreserveMode preserve_mode)
|
|
{
|
|
int dst_fd = creat(dst_path.characters(), 0666);
|
|
if (dst_fd < 0) {
|
|
if (errno != EISDIR)
|
|
return CopyError { errno, false };
|
|
|
|
auto dst_dir_path = DeprecatedString::formatted("{}/{}", dst_path, LexicalPath::basename(source.filename()));
|
|
dst_fd = creat(dst_dir_path.characters(), 0666);
|
|
if (dst_fd < 0)
|
|
return CopyError { errno, false };
|
|
}
|
|
|
|
ScopeGuard close_fd_guard([dst_fd]() { ::close(dst_fd); });
|
|
|
|
if (src_stat.st_size > 0) {
|
|
if (ftruncate(dst_fd, src_stat.st_size) < 0)
|
|
return CopyError { errno, false };
|
|
}
|
|
|
|
for (;;) {
|
|
char buffer[32768];
|
|
ssize_t nread = ::read(source.fd(), buffer, sizeof(buffer));
|
|
if (nread < 0) {
|
|
return CopyError { errno, false };
|
|
}
|
|
if (nread == 0)
|
|
break;
|
|
ssize_t remaining_to_write = nread;
|
|
char* bufptr = buffer;
|
|
while (remaining_to_write) {
|
|
ssize_t nwritten = ::write(dst_fd, bufptr, remaining_to_write);
|
|
if (nwritten < 0)
|
|
return CopyError { errno, false };
|
|
|
|
VERIFY(nwritten > 0);
|
|
remaining_to_write -= nwritten;
|
|
bufptr += nwritten;
|
|
}
|
|
}
|
|
|
|
auto my_umask = umask(0);
|
|
umask(my_umask);
|
|
// NOTE: We don't copy the set-uid and set-gid bits unless requested.
|
|
if (!has_flag(preserve_mode, PreserveMode::Permissions))
|
|
my_umask |= 06000;
|
|
|
|
if (fchmod(dst_fd, src_stat.st_mode & ~my_umask) < 0)
|
|
return CopyError { errno, false };
|
|
|
|
if (has_flag(preserve_mode, PreserveMode::Ownership)) {
|
|
if (fchown(dst_fd, src_stat.st_uid, src_stat.st_gid) < 0)
|
|
return CopyError { errno, false };
|
|
}
|
|
|
|
if (has_flag(preserve_mode, PreserveMode::Timestamps)) {
|
|
struct timespec times[2] = {
|
|
#ifdef AK_OS_MACOS
|
|
src_stat.st_atimespec,
|
|
src_stat.st_mtimespec,
|
|
#else
|
|
src_stat.st_atim,
|
|
src_stat.st_mtim,
|
|
#endif
|
|
};
|
|
if (utimensat(AT_FDCWD, dst_path.characters(), times, 0) < 0)
|
|
return CopyError { errno, false };
|
|
}
|
|
|
|
return {};
|
|
}
|
|
|
|
ErrorOr<void, File::CopyError> File::copy_directory(DeprecatedString const& dst_path, DeprecatedString const& src_path, struct stat const& src_stat, LinkMode link, PreserveMode preserve_mode)
|
|
{
|
|
if (mkdir(dst_path.characters(), 0755) < 0)
|
|
return CopyError { errno, false };
|
|
|
|
DeprecatedString src_rp = File::real_path_for(src_path);
|
|
src_rp = DeprecatedString::formatted("{}/", src_rp);
|
|
DeprecatedString dst_rp = File::real_path_for(dst_path);
|
|
dst_rp = DeprecatedString::formatted("{}/", dst_rp);
|
|
|
|
if (!dst_rp.is_empty() && dst_rp.starts_with(src_rp))
|
|
return CopyError { errno, false };
|
|
|
|
DirIterator di(src_path, DirIterator::SkipParentAndBaseDir);
|
|
if (di.has_error())
|
|
return CopyError { errno, false };
|
|
|
|
while (di.has_next()) {
|
|
DeprecatedString filename = di.next_path();
|
|
auto result = copy_file_or_directory(
|
|
DeprecatedString::formatted("{}/{}", dst_path, filename),
|
|
DeprecatedString::formatted("{}/{}", src_path, filename),
|
|
RecursionMode::Allowed, link, AddDuplicateFileMarker::Yes, preserve_mode);
|
|
if (result.is_error())
|
|
return result.error();
|
|
}
|
|
|
|
auto my_umask = umask(0);
|
|
umask(my_umask);
|
|
|
|
if (chmod(dst_path.characters(), src_stat.st_mode & ~my_umask) < 0)
|
|
return CopyError { errno, false };
|
|
|
|
if (has_flag(preserve_mode, PreserveMode::Ownership)) {
|
|
if (chown(dst_path.characters(), src_stat.st_uid, src_stat.st_gid) < 0)
|
|
return CopyError { errno, false };
|
|
}
|
|
|
|
if (has_flag(preserve_mode, PreserveMode::Timestamps)) {
|
|
struct timespec times[2] = {
|
|
#ifdef AK_OS_MACOS
|
|
src_stat.st_atimespec,
|
|
src_stat.st_mtimespec,
|
|
#else
|
|
src_stat.st_atim,
|
|
src_stat.st_mtim,
|
|
#endif
|
|
};
|
|
if (utimensat(AT_FDCWD, dst_path.characters(), times, 0) < 0)
|
|
return CopyError { errno, false };
|
|
}
|
|
|
|
return {};
|
|
}
|
|
|
|
ErrorOr<void> File::link_file(DeprecatedString const& dst_path, DeprecatedString const& src_path)
|
|
{
|
|
int duplicate_count = 0;
|
|
while (access(get_duplicate_name(dst_path, duplicate_count).characters(), F_OK) == 0) {
|
|
++duplicate_count;
|
|
}
|
|
if (duplicate_count != 0) {
|
|
return link_file(get_duplicate_name(dst_path, duplicate_count), src_path);
|
|
}
|
|
if (symlink(src_path.characters(), dst_path.characters()) < 0)
|
|
return Error::from_errno(errno);
|
|
return {};
|
|
}
|
|
|
|
ErrorOr<void> File::remove(StringView path, RecursionMode mode)
|
|
{
|
|
auto path_stat = TRY(Core::System::lstat(path));
|
|
|
|
if (S_ISDIR(path_stat.st_mode) && mode == RecursionMode::Allowed) {
|
|
auto di = DirIterator(path, DirIterator::SkipParentAndBaseDir);
|
|
if (di.has_error())
|
|
return Error::from_errno(di.error());
|
|
|
|
while (di.has_next()) {
|
|
TRY(remove(di.next_full_path(), RecursionMode::Allowed));
|
|
}
|
|
|
|
TRY(Core::System::rmdir(path));
|
|
} else {
|
|
TRY(Core::System::unlink(path));
|
|
}
|
|
|
|
return {};
|
|
}
|
|
|
|
Optional<DeprecatedString> File::resolve_executable_from_environment(StringView filename)
|
|
{
|
|
if (filename.is_empty())
|
|
return {};
|
|
|
|
// Paths that aren't just a file name generally count as already resolved.
|
|
if (filename.contains('/')) {
|
|
if (access(DeprecatedString { filename }.characters(), X_OK) != 0)
|
|
return {};
|
|
|
|
return filename;
|
|
}
|
|
|
|
auto const* path_str = getenv("PATH");
|
|
StringView path;
|
|
if (path_str)
|
|
path = { path_str, strlen(path_str) };
|
|
if (path.is_empty())
|
|
path = DEFAULT_PATH_SV;
|
|
|
|
auto directories = path.split_view(':');
|
|
|
|
for (auto directory : directories) {
|
|
auto file = DeprecatedString::formatted("{}/{}", directory, filename);
|
|
|
|
if (access(file.characters(), X_OK) == 0)
|
|
return file;
|
|
}
|
|
|
|
return {};
|
|
};
|
|
|
|
}
|