Skip to content

Commit 090071d

Browse files
committed
feat(project): add immutable package content store contract
1 parent 2a4b4a6 commit 090071d

6 files changed

Lines changed: 597 additions & 0 deletions

File tree

Lines changed: 169 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,169 @@
1+
/** @file PackageContentStore.hpp
2+
* Verified immutable package-content availability semantics.
3+
*/
4+
#ifndef VIX_CLI_PROJECT_NATIVE_PACKAGE_CONTENT_STORE_HPP
5+
#define VIX_CLI_PROJECT_NATIVE_PACKAGE_CONTENT_STORE_HPP
6+
7+
#include <cstddef>
8+
#include <memory>
9+
#include <optional>
10+
#include <string>
11+
#include <string_view>
12+
#include <utility>
13+
#include <vector>
14+
15+
#include <vix/cli/project/native/Lockfile.hpp>
16+
17+
namespace vix::cli::project::native
18+
{
19+
class PackageContentId final
20+
{
21+
public:
22+
[[nodiscard]] static std::optional<PackageContentId> parse(std::string digest);
23+
[[nodiscard]] const std::string &digest() const noexcept { return digest_; }
24+
friend bool operator==(const PackageContentId &, const PackageContentId &) = default;
25+
friend bool operator<(const PackageContentId &left, const PackageContentId &right) noexcept
26+
{ return left.digest_ < right.digest_; }
27+
private:
28+
explicit PackageContentId(std::string digest) : digest_(std::move(digest)) {}
29+
std::string digest_;
30+
};
31+
32+
// Candidate representation is deliberately opaque: archive/tree/Git bytes
33+
// and their canonical digest representation belong to a later contract.
34+
// A verifier must bind a stable candidate handle; callers must not mutate
35+
// candidate state after verification.
36+
class PackageContentCandidate
37+
{
38+
public:
39+
virtual ~PackageContentCandidate() = default;
40+
};
41+
42+
class VerifiedPackageContent final
43+
{
44+
public:
45+
[[nodiscard]] const PackageContentId &id() const noexcept { return id_; }
46+
private:
47+
friend class PackageContentVerifier;
48+
friend class InMemoryPackageContentStore;
49+
VerifiedPackageContent(PackageContentId id,
50+
std::shared_ptr<const PackageContentCandidate> candidate, std::string equivalenceKey)
51+
: id_(std::move(id)), candidate_(std::move(candidate)), equivalenceKey_(std::move(equivalenceKey)) {}
52+
PackageContentId id_;
53+
std::shared_ptr<const PackageContentCandidate> candidate_;
54+
// Produced by the verifier; used only to detect contradictory test or
55+
// backend attestations for the same immutable content identity.
56+
std::string equivalenceKey_;
57+
};
58+
59+
enum class PackageContentIssueCode
60+
{
61+
package_content_verification_failed,
62+
package_content_unavailable,
63+
package_content_conflict,
64+
invalid_content_id,
65+
invalid_lockfile
66+
};
67+
68+
struct PackageContentIssue
69+
{
70+
PackageContentIssueCode code{PackageContentIssueCode::package_content_verification_failed};
71+
std::string digest;
72+
std::vector<std::string> nodeIds;
73+
std::string message;
74+
};
75+
76+
struct PackageContentVerificationResult
77+
{
78+
std::optional<VerifiedPackageContent> value;
79+
std::vector<PackageContentIssue> issues;
80+
[[nodiscard]] bool valid() const noexcept { return value.has_value() && issues.empty(); }
81+
};
82+
83+
class PackageContentVerifier
84+
{
85+
public:
86+
virtual ~PackageContentVerifier() = default;
87+
[[nodiscard]] virtual PackageContentVerificationResult verify(
88+
std::shared_ptr<const PackageContentCandidate> candidate,
89+
const PackageContentId &expected) const = 0;
90+
protected:
91+
// Concrete verification policy is supplied by a later acquisition layer.
92+
// This factory lets that verifier mint the internal capability without
93+
// making VerifiedPackageContent publicly constructible.
94+
[[nodiscard]] PackageContentVerificationResult verified_content(
95+
std::shared_ptr<const PackageContentCandidate> candidate,
96+
const PackageContentId &id, std::string equivalenceKey) const;
97+
[[nodiscard]] PackageContentVerificationResult verification_failure(
98+
const PackageContentId &id, std::string message) const;
99+
};
100+
101+
enum class PackageContentAvailability { missing, available };
102+
103+
struct PackageContentStoreResult
104+
{
105+
std::optional<PackageContentAvailability> value;
106+
std::vector<PackageContentIssue> issues;
107+
[[nodiscard]] bool valid() const noexcept { return value.has_value() && issues.empty(); }
108+
};
109+
110+
struct PackageContentAdmissionResult
111+
{
112+
bool admitted{false};
113+
std::vector<PackageContentIssue> issues;
114+
[[nodiscard]] bool valid() const noexcept { return admitted && issues.empty(); }
115+
};
116+
117+
class PackageContentStore
118+
{
119+
public:
120+
virtual ~PackageContentStore() = default;
121+
[[nodiscard]] virtual PackageContentStoreResult inspect(const PackageContentId &id) const = 0;
122+
[[nodiscard]] virtual PackageContentAdmissionResult admit(const VerifiedPackageContent &content) = 0;
123+
};
124+
125+
class InMemoryPackageContentStore final : public PackageContentStore
126+
{
127+
public:
128+
[[nodiscard]] PackageContentStoreResult inspect(const PackageContentId &id) const override;
129+
[[nodiscard]] PackageContentAdmissionResult admit(const VerifiedPackageContent &content) override;
130+
void clear() noexcept { entries_.clear(); }
131+
[[nodiscard]] std::size_t size() const noexcept { return entries_.size(); }
132+
private:
133+
std::vector<VerifiedPackageContent> entries_;
134+
};
135+
136+
struct PackageContentRequirement
137+
{
138+
PackageContentId id;
139+
std::vector<std::string> nodeIds;
140+
};
141+
142+
struct PackageContentPlanResult
143+
{
144+
std::vector<PackageContentRequirement> required;
145+
std::vector<PackageContentIssue> issues;
146+
[[nodiscard]] bool valid() const noexcept { return issues.empty(); }
147+
};
148+
149+
struct PackageContentAvailabilityReport
150+
{
151+
bool complete{false};
152+
std::vector<PackageContentRequirement> missing;
153+
std::vector<PackageContentIssue> issues;
154+
// Missing content is a valid availability result (incomplete, not a
155+
// malformed check); only planning/store inspection failures invalidate it.
156+
[[nodiscard]] bool valid() const noexcept
157+
{
158+
for (const auto &problem : issues)
159+
if (problem.code != PackageContentIssueCode::package_content_unavailable) return false;
160+
return true;
161+
}
162+
};
163+
164+
[[nodiscard]] PackageContentPlanResult required_package_content(const Lockfile &lockfile);
165+
[[nodiscard]] PackageContentAvailabilityReport check_package_content_availability(
166+
const Lockfile &lockfile, const PackageContentStore &store);
167+
} // namespace vix::cli::project::native
168+
169+
#endif // VIX_CLI_PROJECT_NATIVE_PACKAGE_CONTENT_STORE_HPP

‎include/vix/cli/project/native/Validation.hpp‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,7 @@ namespace vix::cli::project::native
7878
[[nodiscard]] bool valid_registry_name(std::string_view value) noexcept;
7979
[[nodiscard]] bool valid_semver(const Version &version) noexcept;
8080
[[nodiscard]] bool valid_requirement(const VersionRequirement &requirement) noexcept;
81+
[[nodiscard]] bool valid_package_content_digest(std::string_view value) noexcept;
8182
[[nodiscard]] std::string normalize_project_relative_path(std::string_view path);
8283

8384
[[nodiscard]] ValidationResult validate_native_project(const NativeProject &project);
Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,132 @@
1+
#include <vix/cli/project/native/PackageContentStore.hpp>
2+
3+
#include <algorithm>
4+
#include <map>
5+
#include <type_traits>
6+
#include <utility>
7+
8+
#include <vix/cli/project/native/Validation.hpp>
9+
10+
namespace vix::cli::project::native
11+
{
12+
namespace
13+
{
14+
PackageContentIssue issue(PackageContentIssueCode code, std::string digest,
15+
std::vector<std::string> nodeIds, std::string message)
16+
{ return {code, std::move(digest), std::move(nodeIds), std::move(message)}; }
17+
18+
const std::string &node_id(const LockNode &node)
19+
{ return std::visit([](const auto &value) -> const std::string & { return value.id; }, node); }
20+
}
21+
22+
std::optional<PackageContentId> PackageContentId::parse(std::string digest)
23+
{
24+
if (!valid_package_content_digest(digest)) return std::nullopt;
25+
return PackageContentId{std::move(digest)};
26+
}
27+
28+
PackageContentVerificationResult PackageContentVerifier::verified_content(
29+
std::shared_ptr<const PackageContentCandidate> candidate,
30+
const PackageContentId &id, std::string equivalenceKey) const
31+
{
32+
if (!candidate || equivalenceKey.empty())
33+
return verification_failure(id, "verifier cannot issue a token without candidate and equivalence identity");
34+
return {VerifiedPackageContent{id, std::move(candidate), std::move(equivalenceKey)}, {}};
35+
}
36+
37+
PackageContentVerificationResult PackageContentVerifier::verification_failure(
38+
const PackageContentId &id, std::string message) const
39+
{
40+
return {std::nullopt, {issue(PackageContentIssueCode::package_content_verification_failed,
41+
id.digest(), {}, std::move(message))}};
42+
}
43+
44+
PackageContentStoreResult InMemoryPackageContentStore::inspect(const PackageContentId &id) const
45+
{
46+
const auto found = std::find_if(entries_.begin(), entries_.end(), [&](const auto &entry) { return entry.id_ == id; });
47+
return {found == entries_.end() ? PackageContentAvailability::missing : PackageContentAvailability::available, {}};
48+
}
49+
50+
PackageContentAdmissionResult InMemoryPackageContentStore::admit(const VerifiedPackageContent &content)
51+
{
52+
if (!content.candidate_ || content.equivalenceKey_.empty() ||
53+
!valid_package_content_digest(content.id_.digest()))
54+
return {false, {issue(PackageContentIssueCode::invalid_content_id, content.id_.digest(), {},
55+
"verified content token is structurally invalid")}};
56+
const auto found = std::find_if(entries_.begin(), entries_.end(), [&](const auto &entry) { return entry.id_ == content.id_; });
57+
if (found != entries_.end())
58+
{
59+
if (found->equivalenceKey_ == content.equivalenceKey_) return {true, {}};
60+
return {false, {issue(PackageContentIssueCode::package_content_conflict, content.id_.digest(), {},
61+
"different verified content claims an already available content identity")}};
62+
}
63+
entries_.push_back(content);
64+
std::sort(entries_.begin(), entries_.end(), [](const auto &left, const auto &right) { return left.id_ < right.id_; });
65+
return {true, {}};
66+
}
67+
68+
PackageContentPlanResult required_package_content(const Lockfile &lockfile)
69+
{
70+
PackageContentPlanResult result;
71+
const auto validation = validate_lockfile(lockfile);
72+
if (!validation.valid())
73+
for (const auto &problem : validation.issues)
74+
result.issues.push_back(issue(PackageContentIssueCode::invalid_lockfile, problem.subject, {}, problem.message));
75+
const auto integrity = validate_lockfile_integrity(lockfile);
76+
if (!integrity.valid())
77+
for (const auto &problem : integrity.issues)
78+
result.issues.push_back(issue(PackageContentIssueCode::invalid_lockfile, problem.subject, {}, problem.message));
79+
if (!result.issues.empty()) return result;
80+
81+
std::map<std::string, std::vector<std::string>> byDigest;
82+
for (const auto &node : lockfile.nodes)
83+
{
84+
const auto &id = node_id(node);
85+
const auto digest = std::visit([](const auto &value) { return value.contentDigest; }, node);
86+
if (!PackageContentId::parse(digest))
87+
{
88+
result.issues.push_back(issue(PackageContentIssueCode::invalid_content_id, digest, {id},
89+
"lock node contains an invalid package content digest"));
90+
continue;
91+
}
92+
byDigest[digest].push_back(id);
93+
}
94+
if (!result.issues.empty()) return result;
95+
for (auto &[digest, nodeIds] : byDigest)
96+
{
97+
std::sort(nodeIds.begin(), nodeIds.end());
98+
result.required.push_back({*PackageContentId::parse(digest), std::move(nodeIds)});
99+
}
100+
return result;
101+
}
102+
103+
PackageContentAvailabilityReport check_package_content_availability(
104+
const Lockfile &lockfile, const PackageContentStore &store)
105+
{
106+
PackageContentAvailabilityReport report;
107+
const auto plan = required_package_content(lockfile);
108+
if (!plan.valid())
109+
{
110+
report.issues = plan.issues;
111+
return report;
112+
}
113+
report.complete = true;
114+
for (const auto &required : plan.required)
115+
{
116+
const auto status = store.inspect(required.id);
117+
if (!status.valid())
118+
{
119+
report.complete = false;
120+
report.issues.insert(report.issues.end(), status.issues.begin(), status.issues.end());
121+
}
122+
else if (*status.value == PackageContentAvailability::missing)
123+
{
124+
report.complete = false;
125+
report.missing.push_back(required);
126+
report.issues.push_back(issue(PackageContentIssueCode::package_content_unavailable,
127+
required.id.digest(), required.nodeIds, "required immutable package content is not available"));
128+
}
129+
}
130+
return report;
131+
}
132+
} // namespace vix::cli::project::native

‎src/project/native/Validation.cpp‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -335,6 +335,7 @@ namespace vix::cli::project::native
335335
bool valid_registry_id(std::string_view value) noexcept { return valid_registry(value); }
336336
bool valid_registry_name(std::string_view value) noexcept { return valid_dashed_identifier(value); }
337337
bool valid_semver(const Version &version) noexcept { return valid_version(version); }
338+
bool valid_package_content_digest(std::string_view value) noexcept { return valid_content_digest(value); }
338339
bool valid_requirement(const VersionRequirement &requirement) noexcept
339340
{
340341
return std::visit([](const auto &mode)

‎tests/CMakeLists.txt‎

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -390,6 +390,26 @@ target_include_directories(vix_cli_native_filesystem_metadata_storage_tests SYST
390390
add_test(NAME vix_cli_native_filesystem_metadata_storage_tests
391391
COMMAND vix_cli_native_filesystem_metadata_storage_tests)
392392

393+
add_executable(vix_cli_native_package_content_store_tests
394+
NativePackageContentStoreTests.cpp
395+
../src/project/native/PackageContentStore.cpp
396+
../src/project/native/MetadataStorage.cpp
397+
../src/project/native/RegistrySnapshotState.cpp
398+
../src/project/native/RegistryAuthority.cpp
399+
../src/project/native/RegistryDirectory.cpp
400+
../src/project/native/Lockfile.cpp
401+
../src/project/native/CanonicalHash.cpp
402+
../src/project/native/Validation.cpp
403+
)
404+
target_include_directories(vix_cli_native_package_content_store_tests PRIVATE
405+
${CMAKE_CURRENT_SOURCE_DIR}/../include
406+
)
407+
target_include_directories(vix_cli_native_package_content_store_tests SYSTEM PRIVATE
408+
${CMAKE_CURRENT_SOURCE_DIR}/../third_party/tomlplusplus
409+
)
410+
add_test(NAME vix_cli_native_package_content_store_tests
411+
COMMAND vix_cli_native_package_content_store_tests)
412+
393413
add_executable(vix_cli_ctest_execution_tests
394414
CTestExecutionTests.cpp
395415
../src/project/cmake/CTestExecution.cpp

0 commit comments

Comments
 (0)