glove/tests/supervisor/change_manifest_test.cpp

175 lines
6.4 KiB
C++

#include "glove/supervisor/change_manifest.hpp"
#include <fcntl.h>
#include <sys/stat.h>
#include <unistd.h>
#include <algorithm>
#include <array>
#include <cstdio>
#include <filesystem>
#include <fstream>
#include <string>
#include <string_view>
#include <system_error>
namespace {
#define REQUIRE(cond) \
do { \
if (!(cond)) { \
std::fprintf(stderr, "REQUIRE failed: %s @ %s:%d\n", #cond, __FILE__, __LINE__); \
return 1; \
} \
} while (0)
class temporary_directory {
public:
temporary_directory() {
std::string pattern = "/tmp/glove-change-manifest-test-XXXXXX";
if (char* created = ::mkdtemp(pattern.data()); created != nullptr) {
root_ = created;
}
}
~temporary_directory() {
std::error_code error;
std::filesystem::remove_all(root_, error);
}
[[nodiscard]] auto root() const -> const std::filesystem::path& { return root_; }
private:
std::filesystem::path root_;
};
class descriptor {
public:
explicit descriptor(const std::filesystem::path& path)
: value_{::open(path.c_str(), O_RDONLY | O_DIRECTORY | O_CLOEXEC | O_NOFOLLOW)} {}
~descriptor() {
if (value_ >= 0) {
(void)::close(value_);
}
}
[[nodiscard]] auto get() const noexcept -> int { return value_; }
private:
int value_ = -1;
};
auto run() -> int {
using namespace glove::supervisor;
// Fixed-size storage exercises the public string_view boundary without a
// trailing std::string sentinel. Malformed input must fail closed.
constexpr std::string_view malformed_manifest =
"{\"schema_version\":1,\"session_id\":\"session-1\",\"manifest_digest\":\""
"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\"";
std::array<char, malformed_manifest.size()> unterminated{};
std::ranges::copy(malformed_manifest, unterminated.begin());
REQUIRE(!decode_retained_change_manifest_json(
std::string_view{unterminated.data(), unterminated.size()}
)
.has_value());
temporary_directory base;
temporary_directory staged;
REQUIRE(!base.root().empty());
REQUIRE(!staged.root().empty());
std::ofstream{base.root() / "modify.txt"} << "before\n";
std::ofstream{base.root() / "remove.txt"} << "remove\n";
std::ofstream{base.root() / "rename.txt"} << "same bytes\n";
std::filesystem::copy(
base.root(),
staged.root(),
std::filesystem::copy_options::recursive | std::filesystem::copy_options::overwrite_existing
);
descriptor base_descriptor{base.root()};
REQUIRE(base_descriptor.get() >= 0);
auto baseline = snapshot_path_tree(base_descriptor.get(), 1'048'576);
REQUIRE(baseline.has_value());
auto unsafe_snapshot = *baseline;
unsafe_snapshot.front().path = "../escape";
REQUIRE(!path_snapshot_digest(unsafe_snapshot).has_value());
auto invalid_mode_snapshot = *baseline;
invalid_mode_snapshot.front().mode = 01000U;
REQUIRE(!path_snapshot_digest(invalid_mode_snapshot).has_value());
std::ofstream{staged.root() / "modify.txt", std::ios::trunc} << "after\n";
REQUIRE(std::filesystem::remove(staged.root() / "remove.txt"));
std::filesystem::rename(staged.root() / "rename.txt", staged.root() / "renamed.txt");
std::ofstream{staged.root() / "create.txt"} << "created\n";
descriptor staged_descriptor{staged.root()};
REQUIRE(staged_descriptor.get() >= 0);
auto current = snapshot_path_tree(staged_descriptor.get(), 1'048'576);
REQUIRE(current.has_value());
auto manifest = build_retained_change_manifest(
"session-1",
"workspace",
4,
std::string(64, 'a'),
std::string(64, 'b'),
1'048'576,
*baseline,
*current
);
REQUIRE(manifest.has_value());
REQUIRE(manifest->created == 1);
REQUIRE(manifest->modified == 1);
REQUIRE(manifest->renamed == 1);
REQUIRE(manifest->removed == 1);
REQUIRE(manifest->manifest_digest.size() == 64U);
REQUIRE(manifest->canonical_json.find("/tmp/") == std::string::npos);
auto repeated = build_retained_change_manifest(
"session-1",
"workspace",
4,
std::string(64, 'a'),
std::string(64, 'b'),
1'048'576,
*baseline,
*current
);
REQUIRE(repeated.has_value());
REQUIRE(repeated->canonical_json == manifest->canonical_json);
auto decoded = decode_retained_change_manifest_json(manifest->canonical_json);
REQUIRE(decoded.has_value());
REQUIRE(decoded->manifest_digest == manifest->manifest_digest);
temporary_directory retained_root;
const auto stage = retained_root.root() / "glove-retained-s9-session-1-a9-workspace";
const auto content = stage / "content";
REQUIRE(std::filesystem::create_directories(content));
REQUIRE(::chmod(stage.c_str(), 0700) == 0);
REQUIRE(::chmod(content.c_str(), 0700) == 0);
std::filesystem::copy(
staged.root(),
content,
std::filesystem::copy_options::recursive | std::filesystem::copy_options::overwrite_existing
);
std::ofstream{stage / "manifest.json"} << manifest->canonical_json;
const auto retained_root_canonical = std::filesystem::canonical(retained_root.root()).string();
auto inspected =
inspect_retained_change_stage(retained_root_canonical, "session-1", "workspace");
if (!inspected) {
std::fprintf(stderr, "inspect failed: %s\n", inspected.error().c_str());
}
REQUIRE(inspected.has_value());
REQUIRE(inspected->manifest_digest == manifest->manifest_digest);
std::ofstream{content / "modify.txt", std::ios::app} << "tamper\n";
REQUIRE(!inspect_retained_change_stage(retained_root_canonical, "session-1", "workspace")
.has_value());
REQUIRE(::symlink("modify.txt", (staged.root() / "link").c_str()) == 0);
REQUIRE(!snapshot_path_tree(staged_descriptor.get(), 1'048'576).has_value());
return 0;
}
} // namespace
int main() {
return run();
}