DAEMON's safepath-adversarial.md accepts a TOCTOU residual between its canonicalise-and-check and the download starting, on the stated grounds that CORE's O_NOFOLLOW open of the final file closes it. That flag was never actually set: SparseFile::open used O_WRONLY|O_CREAT|O_CLOEXEC, so a symlink swapped in as the final path component after DAEMON's check would be followed and redirect our pwrites outside the allowed roots. Add O_NOFOLLOW. A symlinked leaf now fails the open with ELOOP, which errno_to_error already maps to Error::path_rejected. Regular files and the O_CREAT of a fresh part file are unaffected; resume (existing regular part file) is unaffected. Test that a symlinked destination is rejected rather than silently followed, and that the link target is never touched. Co-Authored-By: Claude Sonnet 5 <[email protected]> Claude-Session: https://claude.ai/code/session_01HPPSGhiArbvQgwC2DNiURS
187 lines
5.3 KiB
C++
187 lines
5.3 KiB
C++
// vdm/io/sparse_file.cpp
|
|
|
|
#include "vdm/io/sparse_file.hpp"
|
|
|
|
#include <fcntl.h>
|
|
#include <unistd.h>
|
|
|
|
#include <cerrno>
|
|
#include <cstring>
|
|
#include <utility>
|
|
|
|
namespace vdm::io {
|
|
namespace {
|
|
|
|
Error errno_to_error(int e) noexcept {
|
|
switch (e) {
|
|
case ENOSPC:
|
|
case EDQUOT:
|
|
return Error::disk_full;
|
|
case EACCES:
|
|
case EPERM:
|
|
case EROFS:
|
|
return Error::permission_denied;
|
|
case ENOENT:
|
|
case ENOTDIR:
|
|
case EISDIR:
|
|
case ENAMETOOLONG:
|
|
case ELOOP:
|
|
return Error::path_rejected;
|
|
default:
|
|
return Error::io_error;
|
|
}
|
|
}
|
|
|
|
ErrorInfo sys_error(std::string_view what, int e) {
|
|
return ErrorInfo(errno_to_error(e), std::string(what) + ": " + std::strerror(e));
|
|
}
|
|
|
|
} // namespace
|
|
|
|
SparseFile::~SparseFile() {
|
|
if (fd_ >= 0)
|
|
::close(fd_);
|
|
}
|
|
|
|
SparseFile::SparseFile(SparseFile &&o) noexcept
|
|
: fd_(std::exchange(o.fd_, -1)),
|
|
preallocated_(std::exchange(o.preallocated_, false)),
|
|
path_(std::move(o.path_)) {}
|
|
|
|
SparseFile &SparseFile::operator=(SparseFile &&o) noexcept {
|
|
if (this != &o) {
|
|
if (fd_ >= 0)
|
|
::close(fd_);
|
|
fd_ = std::exchange(o.fd_, -1);
|
|
preallocated_ = std::exchange(o.preallocated_, false);
|
|
path_ = std::move(o.path_);
|
|
}
|
|
return *this;
|
|
}
|
|
|
|
void SparseFile::reset() noexcept {
|
|
fd_ = -1;
|
|
preallocated_ = false;
|
|
path_.clear();
|
|
}
|
|
|
|
Result<void> SparseFile::open(std::string_view path) {
|
|
return open(path, OpenOptions{});
|
|
}
|
|
|
|
Result<void> SparseFile::open(std::string_view path, const OpenOptions &opts) {
|
|
if (fd_ >= 0)
|
|
return ErrorInfo(Error::internal, "SparseFile already open");
|
|
|
|
std::string p(path);
|
|
// O_NOFOLLOW: the final component of a download target must never be a symlink, on
|
|
// create or on resume. DAEMON canonicalises the path and checks it against the allowed
|
|
// roots before start(), but a symlink swapped in afterwards would redirect our writes
|
|
// outside those roots (daemon/docs/safepath-adversarial.md leans on this open closing
|
|
// that TOCTOU window). A symlinked leaf fails here with ELOOP -> Error::path_rejected.
|
|
int flags = O_WRONLY | O_CREAT | O_CLOEXEC | O_NOFOLLOW;
|
|
if (opts.truncate_existing)
|
|
flags |= O_TRUNC;
|
|
|
|
int fd = ::open(p.c_str(), flags, 0644);
|
|
if (fd < 0)
|
|
return sys_error("open " + p, errno);
|
|
|
|
bool prealloc = false;
|
|
if (opts.total_size > 0) {
|
|
if (opts.preallocate) {
|
|
// posix_fallocate returns the error number directly and does not set errno.
|
|
int rc = ::posix_fallocate(fd, 0, static_cast<off_t>(opts.total_size));
|
|
if (rc == 0) {
|
|
prealloc = true;
|
|
} else if (rc == EOPNOTSUPP || rc == ENOSYS || rc == EINVAL) {
|
|
if (::ftruncate(fd, static_cast<off_t>(opts.total_size)) != 0) {
|
|
int e = errno;
|
|
::close(fd);
|
|
return sys_error("ftruncate " + p, e);
|
|
}
|
|
} else {
|
|
::close(fd);
|
|
return sys_error("posix_fallocate " + p, rc);
|
|
}
|
|
} else if (!opts.truncate_existing) {
|
|
// Resuming: make sure the file is at least total_size so pwrite offsets land.
|
|
if (::ftruncate(fd, static_cast<off_t>(opts.total_size)) != 0) {
|
|
int e = errno;
|
|
::close(fd);
|
|
return sys_error("ftruncate " + p, e);
|
|
}
|
|
}
|
|
}
|
|
|
|
fd_ = fd;
|
|
preallocated_ = prealloc;
|
|
path_ = std::move(p);
|
|
return ok();
|
|
}
|
|
|
|
Result<void> SparseFile::write_at(std::uint64_t offset, ConstByteSpan data) {
|
|
if (fd_ < 0)
|
|
return ErrorInfo(Error::internal, "write_at on a closed SparseFile");
|
|
|
|
const std::byte *p = data.data();
|
|
std::size_t remaining = data.size();
|
|
off_t pos = static_cast<off_t>(offset);
|
|
|
|
while (remaining > 0) {
|
|
ssize_t n = ::pwrite(fd_, p, remaining, pos);
|
|
if (n < 0) {
|
|
if (errno == EINTR)
|
|
continue;
|
|
return sys_error("pwrite", errno);
|
|
}
|
|
if (n == 0)
|
|
return ErrorInfo(Error::io_error, "pwrite returned 0");
|
|
p += n;
|
|
pos += n;
|
|
remaining -= static_cast<std::size_t>(n);
|
|
}
|
|
return ok();
|
|
}
|
|
|
|
Result<void> SparseFile::sync() {
|
|
if (fd_ < 0)
|
|
return ErrorInfo(Error::internal, "sync on a closed SparseFile");
|
|
while (::fdatasync(fd_) != 0) {
|
|
if (errno == EINTR)
|
|
continue;
|
|
return sys_error("fdatasync", errno);
|
|
}
|
|
return ok();
|
|
}
|
|
|
|
void SparseFile::advise_dontneed(std::uint64_t offset, std::uint64_t len) noexcept {
|
|
if (fd_ < 0 || len == 0)
|
|
return;
|
|
::posix_fadvise(fd_, static_cast<off_t>(offset), static_cast<off_t>(len), POSIX_FADV_DONTNEED);
|
|
}
|
|
|
|
Result<void> SparseFile::resize(std::uint64_t size) {
|
|
if (fd_ < 0)
|
|
return ErrorInfo(Error::internal, "resize on a closed SparseFile");
|
|
while (::ftruncate(fd_, static_cast<off_t>(size)) != 0) {
|
|
if (errno == EINTR)
|
|
continue;
|
|
return sys_error("ftruncate", errno);
|
|
}
|
|
return ok();
|
|
}
|
|
|
|
Result<void> SparseFile::close() {
|
|
if (fd_ < 0)
|
|
return ok();
|
|
int fd = std::exchange(fd_, -1);
|
|
int rc = ::close(fd);
|
|
reset();
|
|
if (rc != 0)
|
|
return sys_error("close", errno);
|
|
return ok();
|
|
}
|
|
|
|
} // namespace vdm::io
|