Arachne 1.0
Arachne - the perpetual stitcher of Wikidata entities.
Loading...
Searching...
No Matches
hardened_transport.cpp
Go to the documentation of this file.
1#include "pheidippides/hardened_transport.hpp"
2
3#include "arachne/contracts.hpp"
4#include "arachne/crypto.hpp"
5
6#include <curl/curl.h>
7#include <nlohmann/json.hpp>
8
9#include <algorithm>
10#include <atomic>
11#include <cerrno>
12#include <chrono>
13#include <condition_variable>
14#include <cstdlib>
15#include <filesystem>
16#include <fstream>
17#include <limits>
18#include <map>
19#include <memory>
20#include <mutex>
21#include <optional>
22#include <set>
23#include <span>
24#include <stdexcept>
25#include <string>
26#include <string_view>
27#include <system_error>
28#include <utility>
29
30#include <fcntl.h>
31#include <unistd.h>
32
33namespace arachne::pheidippides {
34namespace {
35
36 using json = nlohmann::json;
37 namespace fs = std::filesystem;
38
39 constexpr std::uint64_t maximum_policy_milliseconds = 86'400'000U;
40 constexpr std::uint64_t maximum_cache_seconds = 31'536'000U;
41 constexpr std::uint64_t maximum_cache_document_bytes = 64U * 1024U;
42 std::atomic<std::uint64_t> cache_stage_sequence { 0 };
43
44 [[nodiscard]] std::string ascii_lower(std::string value) {
45 std::ranges::transform(value, value.begin(), [](const char character) {
46 return static_cast<char>(
47 std::tolower(static_cast<unsigned char>(character))
48 );
49 });
50 return value;
51 }
52
53 [[nodiscard]] bool safe_token(const std::string_view value) {
54 if (value.empty()
55 || std::isalnum(static_cast<unsigned char>(value.front())) == 0) {
56 return false;
57 }
58 return std::ranges::all_of(value.substr(1), [](const char character) {
59 return std::isalnum(static_cast<unsigned char>(character)) != 0
60 || character == '.' || character == '_' || character == '-';
61 });
62 }
63
64 [[nodiscard]] bool safe_environment_name(const std::string_view value) {
65 if (value.empty()
66 || (std::isalpha(static_cast<unsigned char>(value.front())) == 0
67 && value.front() != '_')) {
68 return false;
69 }
70 return std::ranges::all_of(value.substr(1), [](const char character) {
71 return std::isalnum(static_cast<unsigned char>(character)) != 0
72 || character == '_';
73 });
74 }
75
76 [[nodiscard]] bool safe_http_header_name(const std::string_view value) {
77 if (value.empty()) {
78 return false;
79 }
80 constexpr std::string_view separators = "()<>@,;:\\\"/[]?={} \t";
81 return std::ranges::none_of(value, [&](const char character) {
82 const auto byte = static_cast<unsigned char>(character);
83 return byte < 0x20U || byte == 0x7fU
84 || separators.find(character) != std::string_view::npos;
85 });
86 }
87
89 const std::string_view value, const std::size_t maximum_bytes
90 ) {
91 return !value.empty() && value.size() <= maximum_bytes
92 && std::ranges::none_of(value, [](const char character) {
93 return character == '\0' || character == '\r'
94 || character == '\n';
95 });
96 }
97
99 const json& object, const std::span<const std::string_view> allowed,
100 const std::string_view location
101 ) {
102 if (!object.is_object()) {
103 throw std::invalid_argument(
104 std::string(location) + " must be an object"
105 );
106 }
107 for (const auto& [key, unused] : object.items()) {
108 static_cast<void>(unused);
109 if (std::ranges::find(allowed, key) == allowed.end()) {
110 throw std::invalid_argument(
111 std::string(location) + " contains unknown field " + key
112 );
113 }
114 }
115 }
116
118 const json& object, const std::string_view key,
119 const std::string_view location
120 ) {
121 const auto value = object.find(key);
122 if (value == object.end() || !value->is_object()) {
123 throw std::invalid_argument(
124 std::string(location) + "." + std::string(key)
125 + " must be an object"
126 );
127 }
128 return *value;
129 }
130
131 [[nodiscard]] std::string required_string(
132 const json& object, const std::string_view key,
133 const std::string_view location
134 ) {
135 const auto value = object.find(key);
136 if (value == object.end() || !value->is_string()
137 || value->get_ref<const std::string&>().empty()) {
138 throw std::invalid_argument(
139 std::string(location) + "." + std::string(key)
140 + " must be a non-empty string"
141 );
142 }
143 return value->get<std::string>();
144 }
145
147 const json& object, const std::string_view key,
148 const std::uint64_t fallback, const std::uint64_t minimum,
149 const std::uint64_t maximum, const std::string_view location
150 ) {
151 const auto value = object.find(key);
152 if (value == object.end()) {
153 return fallback;
154 }
155 if (!value->is_number_unsigned() && !value->is_number_integer()) {
156 throw std::invalid_argument(
157 std::string(location) + "." + std::string(key)
158 + " must be an integer"
159 );
160 }
161 std::uint64_t result = 0;
162 try {
163 result = value->get<std::uint64_t>();
164 } catch (const json::exception&) {
165 throw std::invalid_argument(
166 std::string(location) + "." + std::string(key)
167 + " must be non-negative"
168 );
169 }
170 if (result < minimum || result > maximum) {
171 throw std::invalid_argument(
172 std::string(location) + "." + std::string(key)
173 + " is outside the supported range"
174 );
175 }
176 return result;
177 }
178
180 const json& object, const std::string_view key, const bool fallback,
181 const std::string_view location
182 ) {
183 const auto value = object.find(key);
184 if (value == object.end()) {
185 return fallback;
186 }
187 if (!value->is_boolean()) {
188 throw std::invalid_argument(
189 std::string(location) + "." + std::string(key)
190 + " must be boolean"
191 );
192 }
193 return value->get<bool>();
194 }
195
197 std::chrono::milliseconds total { 60'000 };
198 std::chrono::milliseconds connect { 10'000 };
199 std::chrono::milliseconds read { 30'000 };
200 std::chrono::milliseconds write { 30'000 };
201 std::chrono::milliseconds pool { 10'000 };
202 };
203
205 std::size_t maximum_attempts = 3;
206 std::chrono::milliseconds initial_delay { 250 };
207 std::chrono::milliseconds maximum_delay { 10'000 };
208 std::chrono::milliseconds total_delay_budget { 30'000 };
210 };
211
213 std::size_t maximum_concurrency = 4;
214 std::chrono::milliseconds minimum_interval { 0 };
215 };
216
218 std::chrono::seconds ttl { 3'600 };
219 };
220
227
236
238 policy_settings& policy, const json& object,
239 const std::string_view location
240 ) {
241 constexpr std::array allowed {
242 std::string_view { "timeouts" },
243 std::string_view { "retry" },
244 std::string_view { "admission" },
245 std::string_view { "cache" },
246 std::string_view { "redirect_policy" },
247 std::string_view { "maximum_artifact_bytes" },
248 };
249 reject_unknown_keys(object, allowed, location);
250
251 if (const auto value = object.find("timeouts"); value != object.end()) {
252 constexpr std::array fields {
253 std::string_view { "total_ms" },
254 std::string_view { "connect_ms" },
255 std::string_view { "read_ms" },
256 std::string_view { "write_ms" },
257 std::string_view { "pool_ms" },
258 };
259 reject_unknown_keys(
260 *value, fields, std::string(location) + ".timeouts"
261 );
262 policy.timeouts.total = std::chrono::milliseconds(bounded_unsigned(
263 *value, "total_ms",
264 static_cast<std::uint64_t>(policy.timeouts.total.count()), 1U,
265 maximum_policy_milliseconds, std::string(location) + ".timeouts"
266 ));
267 policy.timeouts.connect
268 = std::chrono::milliseconds(bounded_unsigned(
269 *value, "connect_ms",
270 static_cast<std::uint64_t>(policy.timeouts.connect.count()),
271 1U, maximum_policy_milliseconds,
272 std::string(location) + ".timeouts"
273 ));
274 policy.timeouts.read = std::chrono::milliseconds(bounded_unsigned(
275 *value, "read_ms",
276 static_cast<std::uint64_t>(policy.timeouts.read.count()), 1U,
277 maximum_policy_milliseconds, std::string(location) + ".timeouts"
278 ));
279 policy.timeouts.write = std::chrono::milliseconds(bounded_unsigned(
280 *value, "write_ms",
281 static_cast<std::uint64_t>(policy.timeouts.write.count()), 1U,
282 maximum_policy_milliseconds, std::string(location) + ".timeouts"
283 ));
284 policy.timeouts.pool = std::chrono::milliseconds(bounded_unsigned(
285 *value, "pool_ms",
286 static_cast<std::uint64_t>(policy.timeouts.pool.count()), 1U,
287 maximum_policy_milliseconds, std::string(location) + ".timeouts"
288 ));
289 }
290 if (const auto value = object.find("retry"); value != object.end()) {
291 constexpr std::array fields {
292 std::string_view { "maximum_attempts" },
293 std::string_view { "initial_delay_ms" },
294 std::string_view { "maximum_delay_ms" },
295 std::string_view { "total_delay_budget_ms" },
296 std::string_view { "respect_retry_after" },
297 };
298 reject_unknown_keys(
299 *value, fields, std::string(location) + ".retry"
300 );
301 policy.retry.maximum_attempts
302 = static_cast<std::size_t>(bounded_unsigned(
303 *value, "maximum_attempts", policy.retry.maximum_attempts,
304 1U, 20U, std::string(location) + ".retry"
305 ));
306 policy.retry
307 .initial_delay = std::chrono::milliseconds(bounded_unsigned(
308 *value, "initial_delay_ms",
309 static_cast<std::uint64_t>(policy.retry.initial_delay.count()),
310 0U, maximum_policy_milliseconds,
311 std::string(location) + ".retry"
312 ));
313 policy.retry
314 .maximum_delay = std::chrono::milliseconds(bounded_unsigned(
315 *value, "maximum_delay_ms",
316 static_cast<std::uint64_t>(policy.retry.maximum_delay.count()),
317 0U, maximum_policy_milliseconds,
318 std::string(location) + ".retry"
319 ));
320 policy.retry.total_delay_budget
321 = std::chrono::milliseconds(bounded_unsigned(
322 *value, "total_delay_budget_ms",
323 static_cast<std::uint64_t>(
324 policy.retry.total_delay_budget.count()
325 ),
326 0U, maximum_policy_milliseconds,
327 std::string(location) + ".retry"
328 ));
329 policy.retry.respect_retry_after = optional_boolean(
330 *value, "respect_retry_after", policy.retry.respect_retry_after,
331 std::string(location) + ".retry"
332 );
333 }
334 if (const auto value = object.find("admission");
335 value != object.end()) {
336 constexpr std::array fields {
337 std::string_view { "maximum_concurrency" },
338 std::string_view { "minimum_interval_ms" },
339 };
340 reject_unknown_keys(
341 *value, fields, std::string(location) + ".admission"
342 );
343 policy.admission.maximum_concurrency
344 = static_cast<std::size_t>(bounded_unsigned(
345 *value, "maximum_concurrency",
346 policy.admission.maximum_concurrency, 1U, 1'024U,
347 std::string(location) + ".admission"
348 ));
349 policy.admission.minimum_interval
350 = std::chrono::milliseconds(bounded_unsigned(
351 *value, "minimum_interval_ms",
352 static_cast<std::uint64_t>(
353 policy.admission.minimum_interval.count()
354 ),
355 0U, maximum_policy_milliseconds,
356 std::string(location) + ".admission"
357 ));
358 }
359 if (const auto value = object.find("cache"); value != object.end()) {
360 constexpr std::array fields { std::string_view { "ttl_seconds" } };
361 reject_unknown_keys(
362 *value, fields, std::string(location) + ".cache"
363 );
364 policy.cache.ttl = std::chrono::seconds(bounded_unsigned(
365 *value, "ttl_seconds",
366 static_cast<std::uint64_t>(policy.cache.ttl.count()), 0U,
367 maximum_cache_seconds, std::string(location) + ".cache"
368 ));
369 }
370 if (const auto value = object.find("redirect_policy");
371 value != object.end()) {
372 constexpr std::array fields {
373 std::string_view { "follow" },
374 std::string_view { "maximum_redirects" },
375 std::string_view { "allow_https_to_http" },
376 std::string_view { "allowed_hosts" },
377 };
378 reject_unknown_keys(
379 *value, fields, std::string(location) + ".redirect_policy"
380 );
381 policy.redirects.follow = optional_boolean(
382 *value, "follow", policy.redirects.follow,
383 std::string(location) + ".redirect_policy"
384 );
385 policy.redirects.maximum_redirects
386 = static_cast<std::size_t>(bounded_unsigned(
387 *value, "maximum_redirects",
388 policy.redirects.maximum_redirects, 0U, 20U,
389 std::string(location) + ".redirect_policy"
390 ));
391 policy.redirects.allow_https_to_http = optional_boolean(
392 *value, "allow_https_to_http",
393 policy.redirects.allow_https_to_http,
394 std::string(location) + ".redirect_policy"
395 );
396 if (const auto hosts = value->find("allowed_hosts");
397 hosts != value->end()) {
398 if (!hosts->is_array() || hosts->empty()) {
399 throw std::invalid_argument(
400 std::string(location)
401 + ".redirect_policy.allowed_hosts must be a non-empty "
402 "array"
403 );
404 }
405 policy.redirects.allowed_hosts.clear();
406 for (const auto& host : *hosts) {
407 if (!host.is_string()
408 || host.get_ref<const std::string&>().empty()) {
409 throw std::invalid_argument(
410 std::string(location)
411 + ".redirect_policy.allowed_hosts contains an "
412 "invalid host"
413 );
414 }
415 policy.redirects.allowed_hosts.push_back(
416 host.get<std::string>()
417 );
418 }
419 }
420 }
421 policy.maximum_artifact_bytes = bounded_unsigned(
422 object, "maximum_artifact_bytes", policy.maximum_artifact_bytes, 1U,
423 static_cast<std::uint64_t>(
424 std::numeric_limits<std::int64_t>::max()
425 ),
426 location
427 );
428
429 if (policy.timeouts.connect > policy.timeouts.total) {
430 throw std::invalid_argument(
431 std::string(location) + " connect timeout exceeds total timeout"
432 );
433 }
434 if (policy.retry.initial_delay > policy.retry.maximum_delay) {
435 throw std::invalid_argument(
436 std::string(location)
437 + " initial retry delay exceeds maximum retry delay"
438 );
439 }
440 if (!policy.redirects.follow
441 && policy.redirects.maximum_redirects != 0U) {
442 throw std::invalid_argument(
443 std::string(location)
444 + " disables redirects but permits redirect attempts"
445 );
446 }
447 }
448
449 struct parsed_url {
450 std::string normalized;
451 std::string scheme;
452 std::string host;
453 std::string path;
454 unsigned port = 0;
455 };
456
457 [[nodiscard]] std::string curl_url_part(CURLU* url, const CURLUPart part) {
458 char* raw = nullptr;
459 const CURLUcode status = curl_url_get(url, part, &raw, 0);
460 if (status != CURLUE_OK || raw == nullptr) {
461 return {};
462 }
463 std::unique_ptr<char, decltype(&curl_free)> owned(raw, &curl_free);
464 return std::string(raw);
465 }
466
468 const std::string& value, const std::string_view location
469 ) {
470 std::unique_ptr<CURLU, decltype(&curl_url_cleanup)> url(
471 curl_url(), &curl_url_cleanup
472 );
473 if (!url
474 || curl_url_set(url.get(), CURLUPART_URL, value.c_str(), 0)
475 != CURLUE_OK) {
476 throw std::invalid_argument(
477 std::string(location) + " is not an absolute URL"
478 );
479 }
480 parsed_url result;
481 result.scheme = ascii_lower(curl_url_part(url.get(), CURLUPART_SCHEME));
482 result.host = ascii_lower(curl_url_part(url.get(), CURLUPART_HOST));
483 result.path = curl_url_part(url.get(), CURLUPART_PATH);
484 result.normalized = curl_url_part(url.get(), CURLUPART_URL);
485 const std::string port = curl_url_part(url.get(), CURLUPART_PORT);
486 if (result.scheme != "http" && result.scheme != "https") {
487 throw std::invalid_argument(
488 std::string(location) + " must use HTTP or HTTPS"
489 );
490 }
491 if (result.host.empty() || result.normalized.empty()
492 || !curl_url_part(url.get(), CURLUPART_USER).empty()
493 || !curl_url_part(url.get(), CURLUPART_PASSWORD).empty()
494 || !curl_url_part(url.get(), CURLUPART_FRAGMENT).empty()) {
495 throw std::invalid_argument(
496 std::string(location) + " contains an unsafe URL authority"
497 );
498 }
499 if (port.empty()) {
500 result.port = result.scheme == "https" ? 443U : 80U;
501 } else {
502 try {
503 const unsigned long converted = std::stoul(port);
504 if (converted == 0UL || converted > 65'535UL) {
505 throw std::out_of_range("port");
506 }
507 result.port = static_cast<unsigned>(converted);
508 } catch (const std::exception&) {
509 throw std::invalid_argument(
510 std::string(location) + " has an invalid port"
511 );
512 }
513 }
514 if (result.path.empty()) {
515 result.path = "/";
516 }
517 return result;
518 }
519
520 [[nodiscard]] bool
521 within_base_url(const parsed_url& request, const parsed_url& base) {
522 if (request.scheme != base.scheme || request.host != base.host
523 || request.port != base.port) {
524 return false;
525 }
526 if (base.path == "/") {
527 return true;
528 }
529 if (!request.path.starts_with(base.path)) {
530 return false;
531 }
532 return base.path.ends_with('/')
533 || request.path.size() == base.path.size()
534 || request.path.at(base.path.size()) == '/';
535 }
536
542
548
549 class admission_gate final {
550 public:
553
554 [[nodiscard]] bool
555 acquire(const std::chrono::steady_clock::time_point deadline) {
556 std::unique_lock lock(mutex_);
557 for (;;) {
558 const auto now = std::chrono::steady_clock::now();
559 if (now >= deadline) {
560 return false;
561 }
562 const auto pace_ready
563 = last_start_ + settings_.minimum_interval;
564 if (active_ < settings_.maximum_concurrency
565 && now >= pace_ready) {
566 ++active_;
567 last_start_ = now;
568 return true;
569 }
570 if (active_ < settings_.maximum_concurrency) {
571 condition_.wait_until(lock, std::min(deadline, pace_ready));
572 } else {
573 condition_.wait_until(lock, deadline);
574 }
575 }
576 }
577
578 void release() noexcept {
579 {
580 std::lock_guard lock(mutex_);
581 if (active_ != 0U) {
582 --active_;
583 }
584 }
585 condition_.notify_all();
586 }
587
588 private:
590 std::mutex mutex_;
591 std::condition_variable condition_;
592 std::size_t active_ = 0;
593 std::chrono::steady_clock::time_point last_start_
594 = std::chrono::steady_clock::time_point::min();
595 };
596
597 class admission_lease final {
598 public:
599 admission_lease() = default;
600
601 explicit admission_lease(std::shared_ptr<admission_gate> gate)
602 : gate_(std::move(gate)) { }
603
604 admission_lease(const admission_lease&) = delete;
605 admission_lease& operator=(const admission_lease&) = delete;
606 admission_lease(admission_lease&&) noexcept = default;
607 admission_lease& operator=(admission_lease&&) noexcept = default;
608
610 if (gate_) {
611 gate_->release();
612 }
613 }
614
615 private:
616 std::shared_ptr<admission_gate> gate_;
617 };
618
633
635 std::string door_id;
637 std::shared_ptr<admission_gate> admission;
639 };
640
641 [[nodiscard]] std::string configured_authority(const parsed_url& url) {
642 const bool default_port = (url.scheme == "https" && url.port == 443U)
643 || (url.scheme == "http" && url.port == 80U);
644 return url.host
645 + (default_port ? std::string {} : ":" + std::to_string(url.port));
646 }
647
649 parse_authentication(const json& object, const std::string_view location) {
650 constexpr std::array fields {
651 std::string_view { "mode" },
652 std::string_view { "secret_name" },
653 std::string_view { "header_name" },
654 };
655 reject_unknown_keys(object, fields, location);
657 const std::string mode = required_string(object, "mode", location);
658 if (mode == "none") {
659 if (object.size() != 1U) {
660 throw std::invalid_argument(
661 std::string(location)
662 + " mode none cannot declare secret fields"
663 );
664 }
665 return result;
666 }
667 result.secret_name = required_string(object, "secret_name", location);
668 if (!safe_environment_name(result.secret_name)) {
669 throw std::invalid_argument(
670 std::string(location)
671 + ".secret_name is not an environment name"
672 );
673 }
674 if (mode == "bearer_env") {
676 result.header_name = "Authorization";
677 if (object.contains("header_name")) {
678 throw std::invalid_argument(
679 std::string(location)
680 + " bearer_env cannot override its header"
681 );
682 }
683 return result;
684 }
685 if (mode == "header_env") {
687 result.header_name
688 = required_string(object, "header_name", location);
689 const std::string lowered = ascii_lower(result.header_name);
690 if (lowered == "host"
691 || !safe_http_header_name(result.header_name)) {
692 throw std::invalid_argument(
693 std::string(location) + ".header_name is unsafe"
694 );
695 }
696 return result;
697 }
698 throw std::invalid_argument(
699 std::string(location) + ".mode is unsupported"
700 );
701 }
702
704 const fetch_request_v1& request, const transport_status status,
705 std::string message
706 ) {
707 const auto now = std::chrono::system_clock::now();
709 result.artifact_id = "artifact-" + request.request_id;
710 result.request_id = request.request_id;
711 result.door_id = request.door_id;
712 result.operation = request.operation;
713 result.status = status;
714 result.source_url = request.url;
715 result.effective_url = request.url;
716 result.started_at = now;
717 result.completed_at = now;
718 result.error_message = std::move(message);
719 return result;
720 }
721
722 [[nodiscard]] bool sensitive_header(const std::string_view name) {
723 const std::string lowered = ascii_lower(std::string(name));
724 return lowered == "authorization" || lowered == "proxy-authorization"
725 || lowered == "cookie" || lowered == "set-cookie"
726 || lowered == "x-api-key" || lowered == "api-key"
727 || lowered == "x-goog-api-key" || lowered == "x-auth-token"
728 || lowered == "x-access-token" || lowered == "private-token";
729 }
730
731 [[nodiscard]] std::string
733 std::vector<std::pair<std::string, std::string>> headers;
734 headers.reserve(request.headers.size());
735 for (const auto& header : request.headers) {
736 const std::string name = ascii_lower(header.name);
737 const std::string value = sensitive_header(name)
738 ? "sha256:" + crypto::sha256(header.value)
739 : header.value;
740 headers.emplace_back(name, value);
741 }
742 std::ranges::sort(headers);
743 nlohmann::ordered_json identity {
744 { "door_id", request.door_id },
745 { "endpoint_id", request.endpoint_id },
746 { "operation", to_string(request.operation) },
747 { "method", request.method == http_method::get ? "GET" : "POST" },
748 { "url", request.url },
749 { "headers", nlohmann::ordered_json::array() },
750 { "body_sha256",
751 request.body_artifact ? request.body_artifact->sha256
752 : crypto::sha256(request.body) },
753 { "resume_sha256",
754 request.resume_artifact ? request.resume_artifact->sha256
755 : std::string {} },
756 { "expected_sha256",
757 request.expected_sha256.value_or(std::string {}) },
758 { "maximum_bytes", request.max_bytes },
759 };
760 for (const auto& [name, value] : headers) {
761 identity["headers"].push_back(
762 { { "name", name }, { "value", value } }
763 );
764 }
765 return crypto::sha256(
766 arachnespace::contracts::canonical_json(identity)
767 );
768 }
769
771 std::string artifact_ref;
772 std::string sha256;
774 long http_status = 0;
776 };
777
779 const fs::path& root, const std::string& storage_ref,
780 const std::string& expected_sha256, const std::uint64_t expected_size
781 ) {
782 if (!crypto::is_safe_relative_artifact_ref(storage_ref)) {
783 return false;
784 }
785 fs::path current = root;
786 for (const auto& component : fs::path(storage_ref)) {
787 current /= component;
788 std::error_code error;
789 const auto state = fs::symlink_status(current, error);
790 if (error || fs::is_symlink(state)) {
791 return false;
792 }
793 }
794 std::error_code error;
795 if (!fs::is_regular_file(current)
796 || fs::file_size(current, error) != expected_size || error) {
797 return false;
798 }
799 try {
800 return crypto::sha256_file(current) == expected_sha256;
801 } catch (const std::exception&) {
802 return false;
803 }
804 }
805
807 const fs::path& root, const fs::path& cache_root,
808 const std::string& cache_key
809 ) {
810 const fs::path path = cache_root / (cache_key + ".json");
811 std::error_code error;
812 const auto state = fs::symlink_status(path, error);
813 if (error || fs::is_symlink(state) || !fs::is_regular_file(state)) {
814 return std::nullopt;
815 }
816 const auto size = fs::file_size(path, error);
817 if (error || size > maximum_cache_document_bytes) {
818 return std::nullopt;
819 }
820 try {
821 std::ifstream input(path, std::ios::binary);
822 const json document = json::parse(input);
823 if (!document.is_object() || document.value("cache_version", 0) != 1
824 || document.value("cache_key", std::string {}) != cache_key) {
825 return std::nullopt;
826 }
827 cached_artifact result {
828 .artifact_ref = document.at("artifact_ref").get<std::string>(),
829 .sha256 = document.at("sha256").get<std::string>(),
830 .byte_count = document.at("byte_count").get<std::uint64_t>(),
831 .http_status = document.at("http_status").get<long>(),
832 .stored_unix = document.at("stored_unix").get<std::int64_t>(),
833 };
835 root, result.artifact_ref, result.sha256, result.byte_count
836 )) {
837 return std::nullopt;
838 }
839 return result;
840 } catch (const std::exception&) {
841 return std::nullopt;
842 }
843 }
844
846 const fs::path& cache_root, const std::string& cache_key,
847 const acquired_artifact_v1& acquired
848 ) {
849 const auto stored_unix
850 = std::chrono::duration_cast<std::chrono::seconds>(
851 std::chrono::system_clock::now().time_since_epoch()
852 )
853 .count();
854 const nlohmann::ordered_json document {
855 { "cache_version", 1 },
856 { "cache_key", cache_key },
857 { "stored_unix", stored_unix },
858 { "artifact_ref", acquired.artifact_ref },
859 { "sha256", acquired.sha256 },
860 { "byte_count", acquired.byte_count },
861 { "http_status", acquired.http_status },
862 };
863 const std::string bytes
864 = arachnespace::contracts::canonical_json(document) + "\n";
865 const auto sequence
866 = cache_stage_sequence.fetch_add(1U, std::memory_order_relaxed);
867 const fs::path temporary = cache_root
868 / (".stage-" + std::to_string(::getpid()) + "-"
869 + std::to_string(sequence));
870 const fs::path target = cache_root / (cache_key + ".json");
871 const int descriptor = ::open(
872 temporary.c_str(),
873 O_WRONLY | O_CREAT | O_EXCL | O_CLOEXEC | O_NOFOLLOW, 0600
874 );
875 if (descriptor < 0) {
876 return;
877 }
878 std::size_t offset = 0;
879 bool written = true;
880 while (offset < bytes.size()) {
881 const ssize_t count = ::write(
882 descriptor, bytes.data() + offset, bytes.size() - offset
883 );
884 if (count < 0 && errno == EINTR) {
885 continue;
886 }
887 if (count <= 0) {
888 written = false;
889 break;
890 }
891 offset += static_cast<std::size_t>(count);
892 }
893 if (written && ::fsync(descriptor) != 0) {
894 written = false;
895 }
896 if (::close(descriptor) != 0) {
897 written = false;
898 }
899 if (!written) {
900 std::error_code ignored;
901 fs::remove(temporary, ignored);
902 return;
903 }
904 std::error_code error;
905 fs::rename(temporary, target, error);
906 if (error) {
907 std::error_code ignored;
908 fs::remove(temporary, ignored);
909 }
910 }
911
913 const fetch_request_v1& request, const cached_artifact& cached,
914 const delivery_mode mode
915 ) {
916 const auto now = std::chrono::system_clock::now();
918 result.artifact_id = "artifact-" + request.request_id;
919 result.request_id = request.request_id;
920 result.door_id = request.door_id;
921 result.operation = request.operation;
922 result.delivered_via = mode;
924 result.source_url = request.url;
925 result.effective_url = request.url;
926 result.http_status = cached.http_status;
927 result.artifact_ref = cached.artifact_ref;
928 result.sha256 = cached.sha256;
929 result.byte_count = cached.byte_count;
930 result.started_at = now;
931 result.completed_at = now;
932 return result;
933 }
934
936 std::condition_variable condition;
937 bool complete = false;
939 };
940
941} // namespace
942
943struct hardened_transport::implementation final {
944 implementation(fs::path artifact_root, const json& configuration)
947 constexpr std::array root_fields {
948 std::string_view { "format_version" },
949 std::string_view { "defaults" },
950 std::string_view { "doors" },
951 };
952 reject_unknown_keys(configuration, root_fields, "transport");
953 if (configuration.value("format_version", 0) != 1) {
954 throw std::invalid_argument("transport.format_version must be 1");
955 }
956 policy_settings global_policy;
957 apply_settings(
958 global_policy,
959 required_object(configuration, "defaults", "transport"),
960 "transport.defaults"
961 );
962 const auto door_documents = configuration.find("doors");
963 if (door_documents == configuration.end() || !door_documents->is_array()
964 || door_documents->empty()) {
965 throw std::invalid_argument(
966 "transport.doors must be a non-empty array"
967 );
968 }
969 for (std::size_t door_index = 0; door_index < door_documents->size();
970 ++door_index) {
971 const json& document = door_documents->at(door_index);
972 const std::string location
973 = "transport.doors[" + std::to_string(door_index) + "]";
974 constexpr std::array door_fields {
975 std::string_view { "door_id" },
976 std::string_view { "defaults" },
977 std::string_view { "endpoints" },
978 };
979 reject_unknown_keys(document, door_fields, location);
981 door.door_id = required_string(document, "door_id", location);
982 if (!safe_token(door.door_id) || doors.contains(door.door_id)) {
983 throw std::invalid_argument(
984 location + ".door_id is invalid or duplicated"
985 );
986 }
987 door.policy = global_policy;
988 if (const auto defaults = document.find("defaults");
989 defaults != document.end()) {
990 apply_settings(door.policy, *defaults, location + ".defaults");
991 }
992 door.admission
993 = std::make_shared<admission_gate>(door.policy.admission);
994 const auto endpoint_documents = document.find("endpoints");
995 if (endpoint_documents == document.end()
996 || !endpoint_documents->is_array()
997 || endpoint_documents->empty()) {
998 throw std::invalid_argument(
999 location + ".endpoints must be a non-empty array"
1000 );
1001 }
1002 for (std::size_t endpoint_index = 0;
1003 endpoint_index < endpoint_documents->size();
1004 ++endpoint_index) {
1005 const json& endpoint_document
1006 = endpoint_documents->at(endpoint_index);
1007 const std::string endpoint_location = location + ".endpoints["
1008 + std::to_string(endpoint_index) + "]";
1009 constexpr std::array endpoint_fields {
1010 std::string_view { "endpoint_id" },
1011 std::string_view { "protocol" },
1012 std::string_view { "base_url" },
1013 std::string_view { "allowed_methods" },
1014 std::string_view { "authentication" },
1015 std::string_view { "bulk_capable" },
1016 std::string_view { "resumable_download" },
1017 std::string_view { "write_enabled" },
1018 std::string_view { "idempotency_header" },
1019 std::string_view { "allow_insecure_http" },
1020 std::string_view { "policy" },
1021 };
1022 reject_unknown_keys(
1023 endpoint_document, endpoint_fields, endpoint_location
1024 );
1025 endpoint_configuration endpoint;
1026 endpoint.endpoint_id = required_string(
1027 endpoint_document, "endpoint_id", endpoint_location
1028 );
1029 if (!safe_token(endpoint.endpoint_id)
1030 || door.endpoints.contains(endpoint.endpoint_id)) {
1031 throw std::invalid_argument(
1032 endpoint_location
1033 + ".endpoint_id is invalid or duplicated"
1034 );
1035 }
1036 endpoint.protocol = required_string(
1037 endpoint_document, "protocol", endpoint_location
1038 );
1039 constexpr std::array protocols {
1040 std::string_view { "http_file" },
1041 std::string_view { "rest" },
1042 std::string_view { "sparql" },
1043 std::string_view { "oai_pmh" },
1044 std::string_view { "ldes" },
1045 std::string_view { "iiif" },
1046 };
1047 if (std::ranges::find(protocols, endpoint.protocol)
1048 == protocols.end()) {
1049 throw std::invalid_argument(
1050 endpoint_location + ".protocol is unsupported"
1051 );
1052 }
1053 endpoint.base_url = parse_absolute_url(
1054 required_string(
1055 endpoint_document, "base_url", endpoint_location
1056 ),
1057 endpoint_location + ".base_url"
1058 );
1059 endpoint.allow_insecure_http = optional_boolean(
1060 endpoint_document, "allow_insecure_http", false,
1061 endpoint_location
1062 );
1063 if (endpoint.base_url.scheme != "https"
1064 && !endpoint.allow_insecure_http) {
1065 throw std::invalid_argument(
1066 endpoint_location
1067 + " uses HTTP without explicit test/development "
1068 "permission"
1069 );
1070 }
1071 const auto methods = endpoint_document.find("allowed_methods");
1072 if (methods == endpoint_document.end() || !methods->is_array()
1073 || methods->empty()) {
1074 throw std::invalid_argument(
1075 endpoint_location
1076 + ".allowed_methods must be a non-empty array"
1077 );
1078 }
1079 for (const auto& method : *methods) {
1080 if (!method.is_string()) {
1081 throw std::invalid_argument(
1082 endpoint_location
1083 + ".allowed_methods contains a non-string"
1084 );
1085 }
1086 const std::string value = method.get<std::string>();
1087 if (value == "GET") {
1088 endpoint.allowed_methods.insert(http_method::get);
1089 } else if (value == "POST") {
1090 endpoint.allowed_methods.insert(http_method::post);
1091 } else {
1092 throw std::invalid_argument(
1093 endpoint_location
1094 + ".allowed_methods contains an unsupported method"
1095 );
1096 }
1097 }
1098 endpoint.authentication = parse_authentication(
1099 required_object(
1100 endpoint_document, "authentication", endpoint_location
1101 ),
1102 endpoint_location + ".authentication"
1103 );
1104 endpoint.bulk_capable = optional_boolean(
1105 endpoint_document, "bulk_capable", false, endpoint_location
1106 );
1107 endpoint.resumable_download = optional_boolean(
1108 endpoint_document, "resumable_download", false,
1109 endpoint_location
1110 );
1111 endpoint.write_enabled = optional_boolean(
1112 endpoint_document, "write_enabled", false, endpoint_location
1113 );
1114 if (endpoint_document.contains("idempotency_header")) {
1115 endpoint.idempotency_header = required_string(
1116 endpoint_document, "idempotency_header",
1117 endpoint_location
1118 );
1119 const std::string lowered
1121 if (!endpoint.write_enabled || lowered == "host"
1122 || sensitive_header(endpoint.idempotency_header)
1123 || !safe_http_header_name(
1124 endpoint.idempotency_header
1125 )) {
1126 throw std::invalid_argument(
1127 endpoint_location
1128 + ".idempotency_header is unsafe or declared for a "
1129 "read-only endpoint"
1130 );
1131 }
1132 }
1133 endpoint.policy = door.policy;
1134 if (const auto overrides = endpoint_document.find("policy");
1135 overrides != endpoint_document.end()) {
1136 apply_settings(
1137 endpoint.policy, *overrides,
1138 endpoint_location + ".policy"
1139 );
1140 }
1141 if (endpoint.policy.redirects.allowed_hosts.empty()) {
1142 endpoint.policy.redirects.allowed_hosts
1143 = { configured_authority(endpoint.base_url) };
1144 }
1145 endpoint.admission = std::make_shared<admission_gate>(
1146 endpoint.policy.admission
1147 );
1148 door.endpoints.emplace(
1149 endpoint.endpoint_id, std::move(endpoint)
1150 );
1151 }
1152 doors.emplace(door.door_id, std::move(door));
1153 }
1154
1155 std::error_code error;
1156 const auto root_state = fs::symlink_status(this->artifact_root, error);
1157 if (error == std::errc::no_such_file_or_directory) {
1158 error.clear();
1159 fs::create_directories(this->artifact_root, error);
1160 } else if (
1161 !error
1162 && (fs::is_symlink(root_state) || !fs::is_directory(root_state))
1163 ) {
1164 throw std::invalid_argument(
1165 "artifact root must be a real directory"
1166 );
1167 }
1168 if (error) {
1169 throw std::invalid_argument(
1170 "cannot create artifact root: " + error.message()
1171 );
1172 }
1173 cache_root = this->artifact_root / ".pheidippides-cache";
1174 const auto cache_state = fs::symlink_status(cache_root, error);
1175 if (error == std::errc::no_such_file_or_directory) {
1176 error.clear();
1177 fs::create_directory(cache_root, error);
1178 } else if (
1179 !error
1180 && (fs::is_symlink(cache_state) || !fs::is_directory(cache_state))
1181 ) {
1182 throw std::invalid_argument(
1183 "transport cache root must be a real directory"
1184 );
1185 }
1186 if (error) {
1187 throw std::invalid_argument(
1188 "cannot create transport cache root: " + error.message()
1189 );
1190 }
1191 }
1192
1193 [[nodiscard]] acquired_artifact_v1
1194 execute(const fetch_request_v1& original) const {
1195 if (original.target_artifact_ref == ".pheidippides-cache"
1196 || original.target_artifact_ref.starts_with(
1197 ".pheidippides-cache/"
1198 )) {
1199 return policy_failure(
1201 "request output_ref targets reserved transport metadata"
1202 );
1203 }
1204 const auto door_iterator = doors.find(original.door_id);
1205 if (door_iterator == doors.end()) {
1206 return policy_failure(
1208 "request names an unknown door_id"
1209 );
1210 }
1211 const door_configuration& door = door_iterator->second;
1212 const auto endpoint_iterator
1213 = door.endpoints.find(original.endpoint_id);
1214 if (endpoint_iterator == door.endpoints.end()) {
1215 return policy_failure(
1217 "request names an unknown endpoint_id for its door"
1218 );
1219 }
1220 const endpoint_configuration& endpoint = endpoint_iterator->second;
1221 if (!endpoint.allowed_methods.contains(original.method)) {
1222 return policy_failure(
1224 "request method is disabled for this endpoint"
1225 );
1226 }
1228 && !endpoint.bulk_capable) {
1229 return policy_failure(
1231 "endpoint is not declared bulk-capable"
1232 );
1233 }
1235 && !endpoint.resumable_download) {
1236 return policy_failure(
1238 "endpoint is not declared resumable"
1239 );
1240 }
1242 && !endpoint.write_enabled) {
1243 return policy_failure(
1245 "external writes are disabled for this endpoint"
1246 );
1247 }
1248 if (original.operation == transport_operation::external_write
1249 && !original.idempotency_key.empty()
1250 && !safe_header_value(original.idempotency_key, 512U)) {
1251 return policy_failure(
1253 "the external-write idempotency key is not a safe header value"
1254 );
1255 }
1256 parsed_url request_url;
1257 try {
1258 request_url = parse_absolute_url(original.url, "request locator");
1259 } catch (const std::exception& error) {
1260 return policy_failure(
1261 original, transport_status::door_policy_rejected, error.what()
1262 );
1263 }
1264 if (!within_base_url(request_url, endpoint.base_url)) {
1265 return policy_failure(
1267 "request locator is outside the endpoint base URL"
1268 );
1269 }
1270 for (const auto& header : original.headers) {
1271 if (sensitive_header(header.name)) {
1272 return policy_failure(
1273 original, transport_status::door_policy_rejected,
1274 "sensitive request headers must come from a runtime secret "
1275 "reference"
1276 );
1277 }
1278 if (original.operation == transport_operation::external_write
1279 && !endpoint.idempotency_header.empty()
1280 && ascii_lower(header.name)
1281 == ascii_lower(endpoint.idempotency_header)) {
1282 return policy_failure(
1283 original, transport_status::door_policy_rejected,
1284 "the configured idempotency header is managed by the "
1285 "transport"
1286 );
1287 }
1288 }
1289
1290 fetch_request_v1 request = original;
1291 request.timeout
1292 = std::min(request.timeout, endpoint.policy.timeouts.total);
1293 request.connect_timeout = std::min(
1294 request.connect_timeout, endpoint.policy.timeouts.connect
1295 );
1296 request.read_timeout
1297 = std::min(request.read_timeout, endpoint.policy.timeouts.read);
1298 request.write_timeout
1299 = std::min(request.write_timeout, endpoint.policy.timeouts.write);
1300 request.max_bytes = std::min(
1301 request.max_bytes, endpoint.policy.maximum_artifact_bytes
1302 );
1303 request.maximum_attempts = std::min(
1304 request.maximum_attempts, endpoint.policy.retry.maximum_attempts
1305 );
1306 request.initial_retry_delay = endpoint.policy.retry.initial_delay;
1307 request.maximum_retry_delay = endpoint.policy.retry.maximum_delay;
1308 request.total_retry_delay_budget
1309 = endpoint.policy.retry.total_delay_budget;
1310 request.respect_retry_after = endpoint.policy.retry.respect_retry_after;
1311 request.redirects.follow = endpoint.policy.redirects.follow;
1312 request.redirects.maximum_redirects
1313 = endpoint.policy.redirects.maximum_redirects;
1314 request.redirects.allow_https_to_http
1315 = endpoint.policy.redirects.allow_https_to_http;
1316 request.redirects.allowed_hosts
1317 = endpoint.policy.redirects.allowed_hosts;
1319 if (request.idempotency_key.empty()
1320 || endpoint.idempotency_header.empty()) {
1321 request.maximum_attempts = 1U;
1322 } else {
1323 request.headers.push_back(
1324 { endpoint.idempotency_header, request.idempotency_key }
1325 );
1326 }
1327 }
1328
1330 const char* raw
1331 = std::getenv(endpoint.authentication.secret_name.c_str());
1332 if (raw == nullptr || *raw == '\0') {
1333 return policy_failure(
1335 "required runtime secret is unavailable"
1336 );
1337 }
1338 if (!safe_header_value(raw, 16U * 1024U)) {
1339 return policy_failure(
1341 "required runtime secret is not a safe header value"
1342 );
1343 }
1344 const std::string value = endpoint.authentication.mode
1346 ? "Bearer " + std::string(raw)
1347 : std::string(raw);
1348 request.headers.push_back(
1349 { endpoint.authentication.header_name, value }
1350 );
1351 }
1352
1353 const bool cacheable
1355 const std::string cache_key
1356 = cacheable ? request_cache_key(request) : std::string {};
1357 const auto cached = cacheable
1358 ? read_cache_entry(artifact_root, cache_root, cache_key)
1359 : std::nullopt;
1360 if (cached) {
1361 const auto now
1362 = std::chrono::duration_cast<std::chrono::seconds>(
1363 std::chrono::system_clock::now().time_since_epoch()
1364 )
1365 .count();
1366 const bool fresh = now >= cached->stored_unix
1367 && now - cached->stored_unix
1368 <= endpoint.policy.cache.ttl.count();
1370 return cached_receipt(request, *cached, delivery_mode::offline);
1371 }
1372 if (request.freshness == freshness_policy::cache_allowed && fresh) {
1373 return cached_receipt(
1374 request, *cached, delivery_mode::cache_validated
1375 );
1376 }
1378 return cached_receipt(
1379 request, *cached,
1382 );
1383 }
1384 }
1386 return policy_failure(
1388 "offline_only request has no valid cached artifact"
1389 );
1390 }
1391
1392 const auto admission_deadline
1393 = std::chrono::steady_clock::now() + endpoint.policy.timeouts.pool;
1394 std::shared_ptr<flight_state> flight;
1395 if (cacheable) {
1396 std::unique_lock lock(flights_mutex);
1397 if (const auto existing = flights.find(cache_key);
1398 existing != flights.end()) {
1399 flight = existing->second;
1400 if (!flight->condition.wait_until(
1401 lock, admission_deadline,
1402 [&] { return flight->complete; }
1403 )) {
1404 return policy_failure(
1406 "equivalent-read single-flight wait timed out"
1407 );
1408 }
1409 acquired_artifact_v1 result = flight->result;
1410 result.artifact_id = "artifact-" + request.request_id;
1411 result.request_id = request.request_id;
1412 result.door_id = request.door_id;
1413 result.operation = request.operation;
1414 result.source_url = request.url;
1415 if (result.delivered()) {
1417 result.attempts = 0U;
1418 }
1419 return result;
1420 }
1421 flight = std::make_shared<flight_state>();
1422 flights.emplace(cache_key, flight);
1423 }
1424 if (!door.admission->acquire(admission_deadline)) {
1425 acquired_artifact_v1 result = policy_failure(
1427 "door concurrency or pacing admission timed out"
1428 );
1429 complete_flight(cache_key, flight, result);
1430 return result;
1431 }
1432 admission_lease door_lease(door.admission);
1433 if (!endpoint.admission->acquire(admission_deadline)) {
1434 acquired_artifact_v1 result = policy_failure(
1436 "endpoint concurrency or pacing admission timed out"
1437 );
1438 complete_flight(cache_key, flight, result);
1439 return result;
1440 }
1441 admission_lease endpoint_lease(endpoint.admission);
1442
1443 acquired_artifact_v1 result = byte_transport.execute(request);
1444 if (result.delivered() && cacheable && result.http_status >= 200
1445 && result.http_status < 300) {
1446 write_cache_entry(cache_root, cache_key, result);
1447 }
1448 complete_flight(cache_key, flight, result);
1449 return result;
1450 }
1451
1453 const std::string& cache_key,
1454 const std::shared_ptr<flight_state>& flight,
1455 const acquired_artifact_v1& result
1456 ) const {
1457 if (!flight) {
1458 return;
1459 }
1460 {
1461 std::lock_guard lock(flights_mutex);
1462 flight->result = result;
1463 flight->complete = true;
1464 flights.erase(cache_key);
1465 }
1466 flight->condition.notify_all();
1467 }
1468
1470 fs::path cache_root;
1473 mutable std::mutex flights_mutex;
1474 mutable std::map<std::string, std::shared_ptr<flight_state>, std::less<>>
1476};
1477
1478hardened_transport::hardened_transport(
1479 fs::path artifact_root, const json& transport_configuration
1480)
1484 )
1485 ) { }
1486
1487hardened_transport::~hardened_transport() = default;
1488hardened_transport::hardened_transport(hardened_transport&&) noexcept = default;
1489hardened_transport& hardened_transport::operator=(hardened_transport&&) noexcept
1490 = default;
1491
1493hardened_transport::execute(const fetch_request_v1& request) const {
1494 return implementation_->execute(request);
1495}
1496
1498hardened_transport::execute(const nlohmann::json& request_contract) const {
1499 return execute(from_contract(request_contract));
1500}
1501
1502} // namespace arachne::pheidippides
admission_lease & operator=(admission_lease &&) noexcept=default
acquired_artifact_v1 execute(const fetch_request_v1 &request) const
acquired_artifact_v1 execute(const nlohmann::json &request_contract) const
hardened_transport(std::filesystem::path artifact_root, const nlohmann::json &transport_configuration)
hardened_transport(hardened_transport &&) noexcept
hardened_transport & operator=(hardened_transport &&) noexcept
std::string sha256(std::span< const std::byte > bytes)
Definition crypto.cpp:209
std::string sha256_file(const std::filesystem::path &path)
Definition crypto.cpp:221
bool within_base_url(const parsed_url &request, const parsed_url &base)
void write_cache_entry(const fs::path &cache_root, const std::string &cache_key, const acquired_artifact_v1 &acquired)
bool safe_cached_file(const fs::path &root, const std::string &storage_ref, const std::string &expected_sha256, const std::uint64_t expected_size)
void apply_settings(policy_settings &policy, const json &object, const std::string_view location)
authentication_settings parse_authentication(const json &object, const std::string_view location)
bool safe_header_value(const std::string_view value, const std::size_t maximum_bytes)
void reject_unknown_keys(const json &object, const std::span< const std::string_view > allowed, const std::string_view location)
const json & required_object(const json &object, const std::string_view key, const std::string_view location)
acquired_artifact_v1 policy_failure(const fetch_request_v1 &request, const transport_status status, std::string message)
acquired_artifact_v1 cached_receipt(const fetch_request_v1 &request, const cached_artifact &cached, const delivery_mode mode)
std::uint64_t bounded_unsigned(const json &object, const std::string_view key, const std::uint64_t fallback, const std::uint64_t minimum, const std::uint64_t maximum, const std::string_view location)
std::optional< cached_artifact > read_cache_entry(const fs::path &root, const fs::path &cache_root, const std::string &cache_key)
std::string required_string(const json &object, const std::string_view key, const std::string_view location)
bool optional_boolean(const json &object, const std::string_view key, const bool fallback, const std::string_view location)
parsed_url parse_absolute_url(const std::string &value, const std::string_view location)
acquired_artifact_v1 execute(const fetch_request_v1 &original) const
std::map< std::string, std::shared_ptr< flight_state >, std::less<> > flights
void complete_flight(const std::string &cache_key, const std::shared_ptr< flight_state > &flight, const acquired_artifact_v1 &result) const
std::map< std::string, door_configuration, std::less<> > doors
implementation(fs::path artifact_root, const json &configuration)