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
2 changes: 2 additions & 0 deletions docraft/CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -28,11 +28,13 @@ set(DOCRAFT_SOURCES
src/docraft/utils/docraft_font_registry.cc
src/docraft/utils/docraft_parser_utilis.cc
src/docraft/utils/docraft_utf8.cc
src/docraft/utils/docraft_file_utils.cc
include/docraft/utils/docraft_logger.h
include/docraft/utils/docraft_base64.h
include/docraft/utils/docraft_font_resolver.h
include/docraft/utils/docraft_parser_utilis.h
include/docraft/utils/docraft_utf8.h
include/docraft/utils/docraft_file_utils.h
src/docraft/backend/pdf/docraft_haru_backend.cc
include/docraft/backend/pdf/docraft_haru_backend.h
src/docraft/backend/pdf/docraft_haru_backend_providers_factory.cc
Expand Down
64 changes: 64 additions & 0 deletions docraft/include/docraft/utils/docraft_file_utils.h
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
/*
* Copyright 2026 Matteo Cadoni (https://github.com/cadons)
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

#pragma once

#include "docraft/docraft_lib.h"

#include <cstddef>
#include <filesystem>
#include <optional>

namespace docraft::utils {
/**
* @brief Filesystem helpers shared across backends.
*/
class DOCRAFT_LIB DocraftFileUtils
{
public:
DocraftFileUtils() = delete;

/**
* @brief Securely writes raw bytes to a new, uniquely-named temporary file.
*
* The file is created inside a private, owner-only subdirectory of the
* system temp root rather than directly in that root -- the root itself
* (e.g. /tmp, %TEMP%) is shared with every other local user, so a file
* placed straight into it can be read or raced by them while it exists.
* std::filesystem::create_directory() only succeeds if the name didn't
* already exist, which is an exclusive-create against a guessed/pre-planted
* path (CWE-377/CWE-59) on any platform, so no platform-specific temp-file
* API (mkstemp, _mktemp_s, ...) is needed; the directory is then restricted
* to owner-only before the file is written into it.
*
* @param data Raw bytes to write. Must not be null when size > 0.
* @param size Number of bytes to write.
* @return Path to the created file, or std::nullopt on failure. On failure no
* partially-written file or subdirectory is left behind.
*/
static std::optional<std::filesystem::path> write_temp_file(const unsigned char* data, std::size_t size);

/**
* @brief Removes a file, ignoring errors (e.g. already removed).
*
* If path was produced by write_temp_file(), also removes the now-empty
* private subdirectory it was created in.
*
* @param path File to remove.
*/
static void remove_file(const std::filesystem::path& path);
};
}
62 changes: 6 additions & 56 deletions docraft/src/docraft/backend/pdf/docraft_haru_font_backend.cc
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,8 @@

#include "docraft/backend/pdf/docraft_haru_font_backend.h"

#include <cstdio>
#include <filesystem>
#include "docraft/utils/docraft_file_utils.h"

#include <stdexcept>
#include <vector>

Expand Down Expand Up @@ -68,65 +68,15 @@ namespace docraft::backend::pdf {
// through HPDF_LoadTTFontFromFile instead, so this works against any libharu
// version. libharu copies the font bytes into its own internal structures at
// load time, so the temp file can be removed right after the call.
//
// The temp directory is world-writable (CWE-377), so the file itself must be
// created with an atomic exclusive-create primitive that refuses to follow a
// pre-existing file or symlink at the target path (CWE-59) -- POSIX mkstemp /
// Windows O_CREAT|O_EXCL. That atomicity is what actually closes the attack, so
// deliberately not hand-rolling the name with a PRNG (CWE-338): exclusive
// creation fails safely no matter how guessable the name is, so letting the OS
// pick a name it knows to be unused is strictly simpler and no less safe.
std::error_code ec;
const auto tmp_dir = std::filesystem::temp_directory_path(ec);
if (ec) {
const auto tmp_path = docraft::utils::DocraftFileUtils::write_temp_file(data, size);
if (!tmp_path) {
return nullptr;
}

auto tmpl_str = (tmp_dir / "docraft_font_XXXXXX").string();
std::vector<char> tmpl(tmpl_str.begin(), tmpl_str.end());
tmpl.push_back('\0');

#if defined(_WIN32)
if (_mktemp_s(tmpl.data(), tmpl.size()) != 0) {
return nullptr;
}
const std::filesystem::path tmp_path(tmpl.data());
int fd = -1;
if (_sopen_s(&fd, tmp_path.string().c_str(), _O_CREAT | _O_EXCL | _O_WRONLY | _O_BINARY,
_SH_DENYRW, _S_IWRITE) != 0 || fd == -1) {
return nullptr;
}
const auto written = _write(fd, data, static_cast<unsigned int>(size));
_close(fd);
if (written < 0 || static_cast<std::size_t>(written) != size) {
std::filesystem::remove(tmp_path, ec);
return nullptr;
}
#else
const int fd = mkstemp(tmpl.data());
if (fd == -1) {
return nullptr;
}
const std::filesystem::path tmp_path(tmpl.data());
std::size_t total_written = 0;
while (total_written < size) {
const ssize_t n = write(fd, data + total_written, size - total_written);
if (n <= 0) {
break;
}
total_written += static_cast<std::size_t>(n);
}
close(fd);
if (total_written != size) {
std::filesystem::remove(tmp_path, ec);
return nullptr;
}
#endif

const char *result = HPDF_LoadTTFontFromFile(pdf,
tmp_path.string().c_str(),
tmp_path->string().c_str(),
embed ? HPDF_TRUE : HPDF_FALSE);
std::filesystem::remove(tmp_path, ec);
docraft::utils::DocraftFileUtils::remove_file(*tmp_path);
if (!result) {
HPDF_ResetError(pdf);
}
Expand Down
110 changes: 110 additions & 0 deletions docraft/src/docraft/utils/docraft_file_utils.cc
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
/*
* Copyright 2026 Matteo Cadoni (https://github.com/cadons)
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

#include "docraft/utils/docraft_file_utils.h"

#include <format>
#include <fstream>
#include <random>
#include <string>

namespace {
// Name of the file created inside each private per-call subdirectory; the
// subdirectory itself is what guarantees uniqueness, so a fixed name is fine.
constexpr auto kTempFileName = "docraft.tmp";
constexpr auto kTempDirPrefix = "docraft_";
constexpr int kMaxDirCreateAttempts = 8;
}

namespace docraft::utils {
std::optional<std::filesystem::path> DocraftFileUtils::write_temp_file(const unsigned char* data,
std::size_t size)
{
if (!data || size == 0)
{
return std::nullopt;
}

std::error_code ec;
const auto tmp_root = std::filesystem::temp_directory_path(ec);//is safe
if (ec)
{
return std::nullopt;
}

// The system temp root (e.g. /tmp, %TEMP%) is shared with every other
// local user, so writing into it directly lets them read or race the
// file while it exists. Carve out our own subdirectory first:
// create_directory() only succeeds if the name didn't already exist,
// which is an exclusive-create against a guessed/pre-planted path
// (CWE-377/CWE-59) on any platform without needing a platform-specific
// primitive (mkstemp, _mktemp_s, ...); restricting it to owner-only
// right after closes it off to other local users too.
std::mt19937_64 rng(std::random_device{}());
std::filesystem::path private_dir;
for (int attempt = 0; attempt < kMaxDirCreateAttempts; ++attempt)
{
auto candidate = tmp_root / (std::format("{}{:016x}", kTempDirPrefix, rng()));
if (std::filesystem::create_directory(candidate, ec))
{
private_dir = std::move(candidate);
break;
}
}
if (private_dir.empty())
{
return std::nullopt;
}

std::filesystem::permissions(private_dir, std::filesystem::perms::owner_all,
std::filesystem::perm_options::replace, ec);

const auto tmp_path = private_dir / kTempFileName;
std::ofstream out(tmp_path, std::ios::binary | std::ios::trunc);
if (out)
{
out.write(reinterpret_cast<const char*>(data), static_cast<std::streamsize>(size));
}
const bool ok = out.good();
out.close();
if (!ok)
{
remove_file(tmp_path);
std::filesystem::remove(private_dir, ec);
return std::nullopt;
}

return tmp_path;
}

void DocraftFileUtils::remove_file(const std::filesystem::path& path)
{
std::error_code ec;
std::filesystem::remove(path, ec);

// Also clean up the private per-call subdirectory write_temp_file creates
// around its file. Only attempt this for paths that actually look like
// one of ours (right filename, parent dir named with our prefix) -- this
// is a general-purpose file removal helper, so it must never reach for an
// arbitrary caller's parent directory, let alone the shared system temp
// root itself.
const auto& parent = path.parent_path();
if (path.filename() == kTempFileName && parent.filename().string().starts_with(kTempDirPrefix))
{
std::filesystem::remove(parent, ec);
}
}
}
1 change: 1 addition & 0 deletions docraft/test/CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ set(TEST_SOURCES
docraft/utils/docraft_logger_test.cc
docraft/utils/docraft_parser_utils.cc
docraft/utils/docraft_font_registry_test.cc
docraft/utils/docraft_file_utils_test.cc
docraft/utils/docraft_test_temp_file.h
docraft/loom/pipeline/docraft_loom_measure_processor_test.cc
docraft/loom/pipeline/docraft_loom_layout_processor_test.cc
Expand Down
60 changes: 60 additions & 0 deletions docraft/test/docraft/utils/docraft_file_utils_test.cc
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
#include <fstream>
#include <vector>

#include <gtest/gtest.h>

#include "docraft/utils/docraft_file_utils.h"

using docraft::utils::DocraftFileUtils;

TEST(DocraftFileUtilsTest, WriteTempFileReturnsNulloptForNullData)
{
EXPECT_EQ(DocraftFileUtils::write_temp_file(nullptr, 10), std::nullopt);
}

TEST(DocraftFileUtilsTest, WriteTempFileReturnsNulloptForZeroSize)
{
const unsigned char data[] = {1, 2, 3};

Check warning on line 17 in docraft/test/docraft/utils/docraft_file_utils_test.cc

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Replace this C-style array with "std::vector" (for dynamic size), or "std::array" (for static size)

See more on https://sonarcloud.io/project/issues?id=Cadons_Docraft&issues=AaAGL5aEyDvelYEruSmk&open=AaAGL5aEyDvelYEruSmk&pullRequest=68
EXPECT_EQ(DocraftFileUtils::write_temp_file(data, 0), std::nullopt);
}

TEST(DocraftFileUtilsTest, WriteTempFileWritesExactBytesToAFreshUniqueFile)
{
const std::vector<unsigned char> data{'D', 'o', 'c', 'r', 'a', 'f', 't'};

const auto path = DocraftFileUtils::write_temp_file(data.data(), data.size());
ASSERT_TRUE(path.has_value());
ASSERT_TRUE(std::filesystem::exists(*path));

std::ifstream in(*path, std::ios::binary);
const std::vector<char> contents((std::istreambuf_iterator<char>(in)), std::istreambuf_iterator<char>());
ASSERT_EQ(contents.size(), data.size());
EXPECT_TRUE(std::equal(contents.begin(), contents.end(), data.begin()));
// Windows can't delete a file while a handle to it is still open (unlike
// POSIX, where unlinking an open file just removes the directory entry) --
// close the read handle before removing, or remove_file() below silently
// no-ops there and the file is still on disk afterwards.
in.close();

DocraftFileUtils::remove_file(*path);
EXPECT_FALSE(std::filesystem::exists(*path));
}

TEST(DocraftFileUtilsTest, WriteTempFileProducesDistinctPathsAcrossCalls)
{
const unsigned char data[] = {42};

Check warning on line 45 in docraft/test/docraft/utils/docraft_file_utils_test.cc

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Replace this C-style array with "std::vector" (for dynamic size), or "std::array" (for static size)

See more on https://sonarcloud.io/project/issues?id=Cadons_Docraft&issues=AaAGL5aEyDvelYEruSml&open=AaAGL5aEyDvelYEruSml&pullRequest=68

const auto first = DocraftFileUtils::write_temp_file(data, 1);
const auto second = DocraftFileUtils::write_temp_file(data, 1);
ASSERT_TRUE(first.has_value());
ASSERT_TRUE(second.has_value());
EXPECT_NE(*first, *second);

DocraftFileUtils::remove_file(*first);
DocraftFileUtils::remove_file(*second);
}

TEST(DocraftFileUtilsTest, RemoveFileIsSafeOnAMissingPath)
{
DocraftFileUtils::remove_file(std::filesystem::temp_directory_path() / "docraft_never_created.tmp");
}
Loading