mirror of
https://github.com/logos-co/logos-package-manager.git
synced 2026-08-31 04:01:13 +00:00
* feat(deps): evaluate a dependency's version range instead of only carrying it
resolveDependencies called build(dep.name) and discarded dep.version one
character from where it was needed, so a manifest declaring
{"name":"lib","version":"^2.0.0"} with lib 1.0.0 installed reported
"installed". The range travelled intact from metadata.json through the .lgx,
`lgx verify` and `lgpm install` onto disk, and was asked a question nowhere.
build() now takes the whole PackageDependency, because the range and the
signer live on the EDGE rather than on the package and so have to travel with
the recursion, and compares it with logos::semver::satisfies -- already
linked, already called at :114. This was a wiring gap, not a missing
capability; no new semver, no build-system change. A dependency installed at
a version its dependant refuses now reports DependencyStatus::VersionMismatch.
Precedence is deliberate: ABSENCE OUTRANKS MISMATCH. A range can only be
judged against a version we actually have, and "install it" is the remedy
either way, so a dependency that is both absent and constrained still reports
not_installed -- the stronger fact, and the one the user can act on. Naming
the weaker one would point at the wrong fix. Either way the declared range
rides along on the node, so a caller can say WHICH version to install.
An unparseable range is treated as unsatisfied rather than ignored: silently
dropping a typo'd range would fail open, and lgx verify already rejects the
syntax upstream, so a manifest reaching us with one bypassed that gate.
The signer is carried as data and compared by nobody -- who may sign a
dependency is a trust decision that does not belong to the scanner.
VersionMismatch is APPENDED to the enum, never inserted:
logos-package-manager-module compiles against this header and links
libpackage_manager_lib at run time, so existing enumerator values are ABI.
Both new node fields are omitted from the JSON when absent, so a tree of
bare-name dependencies -- every package in the workspace today -- serialises
byte-identically to before.
Tests 137 -> 148. Red on the base, asserted through dependencyStatusToString
so the probe compiles without the new enumerator:
Expected: "version_mismatch" Which is: "installed"
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* test(deps): pin what a diamond does to the flat dependency list
flatten() is the only projection resolveFlatDependencies -- and therefore
basecamp's load gate -- ever reads. It dedupes by name with first-wins, and
BFS reaches the depth-1 edge first, so when a package is named BOTH directly
(bare, as every manifest in the fleet does today) and by a dependency that
constrains it, the unconstrained edge wins and the mismatch is dropped.
Red on b7e2280, measured on the real module over real IPC first:
resolveFlatDependencies appA true -> lib "installed" (masked)
resolveDependencies appA true -> lib "version_mismatch" (tree is right)
Three cases: the diamond, the same graph with the root's entries swapped
(declaration order must not decide it), and a satisfied diamond as the
control that a duplicate is not by itself a mismatch.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* fix(deps): a deeper version mismatch must not be masked by a shallower edge
flatten() deduped by name with first-wins. BFS reaches the shallowest edge
first, so when a package is named BOTH directly by the root -- bare, which is
what every manifest in the fleet does today -- and by a dependency that
constrains it, the unconstrained edge won and the rejection was dropped.
Measured on the real module over real IPC, one installed tree, two calls:
resolveDependencies appA true -> helper -> lib "version_mismatch"
resolveFlatDependencies appA true -> lib "installed"
The tree was right the whole time; the flat projection lost it. That matters
because the flat list is the ONLY one resolveFlatDependencies returns and the
only one basecamp's load gate reads, so a transitive mismatch admitted the
load with no diagnostic anywhere -- the same shape as the bug this series set
out to fix, one projection further down.
A package satisfies its dependants only if it satisfies ALL of them, so a
later edge that rejects what an earlier edge accepted now promotes the row and
carries its range. One row per package is unchanged: this rewrites the row it
already has rather than appending. Only Installed -> VersionMismatch is
promoted; the other statuses are properties of the package (absence is absence
on every edge) or structural (Cycle), and no second edge can contradict them.
Red on 991d092, a runtime failure rather than a compile error:
DeeperMismatchIsNotMaskedByAShallowerBareEdge it->status: installed
expected: version_mismatch
DeeperMismatchSurvivesRegardlessOfDeclarationOrder
Green: 151 -> 151 passed.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* feat(deps): record who published an installed package, and compare a pin to it
A `signer` pin was carried across the whole system and evaluated by nothing.
The comment that said so gave the reason:
`requiredSigner` is carried as data only — nothing in this library compares
it, because who may sign a dependency is a trust decision that does not
belong to the scanner.
That is backwards, and it conflates two different questions:
trust anchor : does any active anchor vouch for this key? -> may it be
installed
signer pin : is this the key the dependant named? -> is it the
same package
The first is authorization. It belongs to the trust-anchor gate in
installPluginFile, against the local keyring, and this walk still consults no
keyring. The second is IDENTITY: a pin is what separates `my_module` published
by me from `my_module` published by anybody else, and when it is present it is
as load-bearing as the name. Resolving identity is exactly what the scanner
does. Skipping it because the trust check lives elsewhere did neither.
THE BLOCKER: nothing recorded who published an installed package. Proven by
positive control — sign a package, anchor its key, install under
--require-signatures, then grep the install tree for did:jwk / signer /
signature: nothing. Every signer string in an install tree was a PIN some
dependant declared. The .lgx is deleted after extraction, so install-time
verification is the one and only moment anybody knows who signed it.
So this records it, following the `variant` sidecar precedent exactly: a
single-line, non-payload file dropped into the extracted variant directory,
copied into the install directory by the same copy, read back at scan time by
readInstalledSigner — the mirror of readInstalledVariant.
WHAT COUNTS AS AN OBSERVATION is a rule, not an inline condition, so it can be
tested: PackageManagerLib::observedSignerFrom requires `signature_valid`, never
`is_signed`, and never `signer_did` alone. logos-package sets signer_did from
manifest.sig BEFORE the Ed25519 check, so until it passes the DID is a CLAIM,
and anybody can write any DID into a manifest.sig. An unsigned package records
nothing; one whose signature failed records nothing; both read back as
"nothing recorded", which is the honest answer in each case.
That the rule is a separate function is deliberate. Today's trust-anchor gate
refuses a failed-signature package before the write site sees one, so
`signature_valid` and `is_signed` produce the same installed tree and no
end-to-end test can tell them apart — measured: a build with the rule weakened
to `is_signed` passed every end-to-end test in the new file. WARN means "warn,
do not refuse"; the day it stops refusing this case, a claim-based rule starts
minting publisher identities from attacker-written strings.
THE DESIGN CALL — a pin present, and the installed package has NO record.
Measured before choosing (whole workspace): 110 metadata.json, 65 declared
dependency edges, ZERO object-form entries — so zero signer pins and zero
version ranges — zero manifest.sig anywhere, and the shared release workflow
publishes `signing_mode: none`. The number of packages either option changes
TODAY is zero, under both. The choice therefore does not turn on blast radius;
it decides whether pins can be adopted at all.
Chosen: LENIENT — its own status, DependencyStatus::SignerUnknown, which does
not claim a mismatch it cannot prove. The decisive fact is EMBEDDED packages:
they are placed by the build and never pass through installPluginFile, so they
can NEVER acquire a record. Under a strict reading, a pin on an embedded
dependency is unsatisfiable by construction, forever, with no action a user
could take. That is not a strict policy, it is a broken one. It is also a
different sentence to say: "this is a different publisher's package" is an
accusation, "nobody recorded who published this" is a gap.
The flip is one line: UnknownSignerPolicy on the enum, `setUnknownSignerPolicy
(::Strict)` per instance, or change the member initialiser build-wide. Strict
makes the scanner emit SignerMismatch instead, so every downstream consumer
fails closed without a change of its own.
RANKING. One edge can fail more than one constraint, and two edges to the same
package can disagree, so both questions now go through one authority,
edgeVerdictSeverity:
Installed(0) < SignerUnknown(1) < VersionMismatch(2) < SignerMismatch(3)
with absence outranking all of it (returned before any of this runs). Missing
evidence ranks BELOW a definite failure so "we could not tell" never masks an
actionable rejection. Identity ranks above a range because a range means
nothing until you know which package you are ranging over: "requires ^2.0.0,
found 1.0.0" sends a user hunting for a newer build of a package that is not
theirs at any version.
flatten()'s promotion is rewritten in terms of that ranking. It was the single
pair `Installed -> VersionMismatch`, which would have gone on silently dropping
a deeper SIGNER mismatch exactly the way it once dropped a deeper version one —
in the only projection basecamp's load gate reads.
Also here, because both are the same defect one notch out:
- nodeResolvedToAnInstalledPackage replaces `Installed || VersionMismatch` at
the serialisation sites. That chain named the statuses that existed when it
was written, so the moment these two were appended it would have started
blanking `version` on packages sitting on disk.
- `lgpm info` and `lgpm --json info` report the signer, so the rest can be
verified without a debugger. Absent prints "(not recorded)", never
"unsigned" — nothing on disk records that distinction.
SignerMismatch warns on stderr once, where it is decided. SignerUnknown does
not: it is the expected state for every embedded package and every pre-sidecar
install, and a warning that fires on the normal case trains readers to ignore
the channel.
test_dependency_resolution's SignerIsCarriedButNotEvaluated asserted the old
behaviour and is rewritten, with the reasoning it encoded and why it was wrong.
RED (both behaviour sites reverted to "carried as data only"): 12 failed / 173
RED (recording rule weakened to the CLAIM, `is_signed`): 2 failed / 174
ObservedSignerTest.OnlyAVerifiedSignatureCountsAsAnObservation
ObservedSignerTest.ForgedSignatureRecordsNothingNotTheClaimedDid
GREEN: 174/174
/nix/store/5y2kn2bapcyh6caskrawbv0dv1pmwpws-logos-package-manager-tests-1.0.0-dev
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* fix(deps): the observed-signer record must be evidence, not a leftover
The sidecar said who published an installed package, but two things other
than the verifier could put a value there, and both let a package wear an
identity nobody checked:
1. THE PACKAGE PLANTS IT. `variants/<v>/` is copied wholesale into the
install directory, so a package author picks filenames that land there.
An UNSIGNED package shipping a file called `signer` holding somebody
else's DID was reported as published by them. No key, no signature, no
keyring — a `signer` pin satisfied by naming the answer.
2. THE PREVIOUS RECORD OUTLIVES ITS PACKAGE. copyDirectoryContents MERGES
into an existing install directory rather than replacing it, so a second
install that verified nothing left the FIRST install's sidecar standing
and an impostor inherited the identity of whatever it replaced.
Both were reachable end to end: an unsigned my_module reported `installed`
against a pin on a key that never touched it.
The recording is now TOTAL and happens AFTER the copy, in the install
directory: write on an observation, DELETE when there is none. Whatever the
package shipped and whatever the last install left are overwritten or
removed, so what remains is this install's own finding. Absent still means
"nothing was verified", which stays a different fact from "unsigned".
Both attacks now land in the already-documented Lenient residual — reported
SignerUnknown rather than claiming an identity — so the pin can no longer be
satisfied, only left unproven.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* fix(deps): a planted signer record shipped read-only survives on Windows only
The authoritative rewrite assumed it could always delete or reopen the
sidecar. A payload file arrives with the package's own permissions, and
everything in the Nix store is 0444 — nix-bundle-lgx copies a module's
`icon:` straight out of it, so shipping a read-only file is ordinary, not
exotic.
POSIX consults the PARENT DIRECTORY's write bit to unlink, so the remove
succeeds there and the fresh write lands. Windows refuses to delete a
FILE_ATTRIBUTE_READONLY file and refuses to reopen it for write, so a
planted `signer` shipped 0444 would have survived on exactly one platform —
the same asymmetry clearReadOnlyRecursive already exists for, and the same
one that made every Windows uninstall fail with "Access is denied".
Clear the bit before touching the file, and fall back to TRUNCATING rather
than a second delete: readInstalledSigner already collapses an empty file to
"nothing recorded", so emptying a file that will not unlink still erases the
claim. Both failure paths now warn instead of passing silently.
The accompanying test cannot go red on Linux and says so in as many words —
deleting there never consults the file's own mode. It discriminates on
Windows, which is where the defect lives.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* fix(deps): a signer pin is answered by verifying, not by trusting a record we wrote
The `signer` sidecar this replaces recorded the DID install had verified. It
was an ASSERTION BY THE INSTALLER, believed because the installer wrote it, and
three forgeries worked against it — each needing its own defence:
A1 a package plants its own record. variants/<v>/ is copied wholesale, so an
author ships a file named `signer` holding the pinned DID. No key, no
signature, no keyring, pin satisfied.
A2 the record outlives its package. copyDirectoryContents merges and never
clears, so an unsigned reinstall inherits the previous publisher's name.
A3 Windows. Everything in the Nix store is 0444, so a read-only plant is
ordinary, and a bare fs::remove cannot clear one.
Evidence that needs defending is not evidence. All of it is gone: the sidecar,
its write site, its read-back, observedSignerFrom, the total write, the
read-only clearing and the truncate fallback.
What replaces it was already in the package and was being thrown away.
Package::signPackage signs manifest_.toJson(); extractLgxPackage writes
manifest.json from lgx_get_manifest_json(), which returns the same expression,
so the installed manifest IS the signed message, byte for byte, by
construction. Only the signature was discarded. It is now carried alongside.
At resolve, THE PIN SUPPLIES THE KEY: did:jwk embeds an Ed25519 public key, so
the pinned DID yields a key with no keyring involved, and the installed
signature is checked over the installed manifest under THAT key. The DID
written inside manifest.sig is never consulted. Reading it, comparing it to the
pin and verifying with that same key would prove only that the file agrees with
itself; a relabelled document — somebody else's genuine signature wearing the
publisher's name — passes that and is refused here.
None of A1, A2 or A3 survives, and nothing recognises them. A planted,
inherited or unclearable manifest.sig is just a file that does not verify.
A2 in particular cannot work any more even in principle: the manifest carries
the Merkle root over the payload, so a signature cannot describe other bytes.
A3 stops being a hazard at all because the signature is written during
EXTRACTION, and Package::extractVariant re-adds owner_write to everything it
writes — the trap existed only because the sidecar was written after the copy.
`observedSigner` is renamed `signerDid`, on the package and on the wire. It no
longer records an observation: it reports what the installed signature says of
itself, once checked against the key its own DID carries — enough to display,
and deliberately not enough to settle identity. The verdict does not come from
comparing it to anything.
Unchanged: SignerUnknown/SignerMismatch, the severity ranking, and the Lenient
default. The argument for Lenient survives the new mechanism intact — embedded
packages never pass through installPluginFile, so they can no more carry a
manifest.sig than they could a sidecar, and under Strict a pin on an embedded
dependency would be unsatisfiable by construction forever.
Needs logos-package feat/carry-the-manifest-signature for
lgx_get_manifest_sig_json / lgx_check_manifest_signature; flake.nix is pinned
to that branch and must go back to the bare URL once it merges.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* docs(deps): say why the resolve path does not re-hash the payload
Carrying the signature makes tamper-evidence newly possible — the signed
manifest holds a Merkle tree over the package contents, so a verified signature
establishes what the payload should hash to. Writing down why resolve does not
do it, since the next reader will notice the same opening.
Cost is the smaller half: the signature check is ~450us and constant in payload
size, re-hashing is linear (~2.4ms for a 2.6 MB module, ~47ms for 50 MB, before
I/O) on a walk basecamp runs per refresh.
The correctness half is the real one. The Merkle tree is over TAR ENTRY PATHS,
and the install tree is flattened and added to: variants/<v>/x.so becomes
<name>/x.so, assets/ merges into the same directory, and extractLgxPackage
synthesises manifest.json, manifest.sig and `variant` there. On a real package
the variant leaf hash covers ONE file while the install directory holds four.
Re-deriving it means re-prefixing paths and excluding exactly the files install
invented, and getting that subtly wrong accuses a good package of tampering.
That belongs in a deliberately-tested verb, run on demand.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* fix(sig): write the installed manifest in BINARY — it is the signed message
installPluginFile wrote manifest.json through a text-mode ofstream. On Windows
that translates '\n' to "\r\n", so the bytes on disk stop being the bytes that
were signed.
The translation is not cancelled on the way back: readFileBytes() at :620
opens BINARY, so the resolve path reads the CRLF form and compares it against
a signature over the LF form. Every check returns MISMATCH — on packages the
pinned key genuinely signed.
Worst-case shape for a defect: silent (no error, just a wrong verdict),
wrong-cause (the user is told the publisher does not match when it does), and
Windows-only, so nothing in CI would ever show it.
Package::signPackage signs getManifest().toJson(); lgx_get_manifest_json()
returns that same expression; this write puts it on disk. All three have to
agree byte for byte or the whole mechanism is inoperative.
The added test asserts the installed manifest carries no carriage return.
Stated in the test itself: on Linux that assertion CANNOT fail, because there
is no translation to do — it guards the platform it names, and a green Linux
run is not evidence.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* fix(deps): a malformed constraint must fail closed, not vanish
F2. The parser gated reading on is_string(), so `{"signer": 42}` produced no
pin at all — the dependent asked to be narrowed and got no check, which is
indistinguishable downstream from never having asked. Fail-OPEN on the two
fields whose entire job is to narrow what satisfies an edge.
It now carries the raw text. That cannot parse as a semver range or as a
did:jwk, so it lands as VersionMismatch / SignerMismatch and the operator sees
the offending value instead of silence.
`lgpm install` cannot reach this — logos-package rejects both shapes first —
so the reachable sources are hand-edited and build-time embedded manifests.
F3 IS DELIBERATELY NOT FIXED, and the reasoning is recorded at the site.
The audit called it a wrong-CAUSE message: an unsigned reinstall over a signed
package leaves the previous publisher's manifest.sig behind, because
copyDirectoryContents merges, and the operator is told the publisher does not
match when the truth is that this package has no publisher.
That is accurate, but deleting the stale file trades a wrong message for a
wrong VERDICT. Measured, both directions:
stale sig kept -> cannot verify against the rewritten manifest
-> SignerMismatch -> BLOCKS
stale sig removed -> SignerUnknown -> does NOT block under Lenient
So an unsigned impostor replacing a signed package would start passing. That
case is a publisher DOWNGRADE with positive evidence for it, which is what
separates it from the never-signed packages Lenient exists to tolerate.
Removing it also broke A2_AStaleSignatureFromThePreviousInstallIsRefused, for
a real reason rather than a stale expectation.
The defect is in the REPORTING layer: "the installed signature does not verify
against this package" and "this package names a different publisher" need to
be different sentences. That is a separate change and does not belong here.
Also reverts the logos-package branch pin to master, now that
logos-co/logos-package#36 has merged.
184/184.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* docs: trim the comments added by this PR
Cut the added comment lines from 816 to 365 and the largest block from 82
to 13. Removed narrative, history, ASCII rules, and points restated in
three registers; kept the non-obvious mechanisms, the load-bearing
orderings, the measured numbers, the deliberate refusals, and the honest
test limits. No code changed.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* chore: drop the stale branch-pin note
The pin it describes is gone — logos-package.url went back to the bare URL
when logos-co/logos-package#36 merged. flake.nix is now identical to master;
only flake.lock differs, carrying logos-package master with
lgx_get_manifest_sig_json / lgx_check_manifest_signature.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
948 lines
38 KiB
C++
948 lines
38 KiB
C++
#include <gtest/gtest.h>
|
|
#include "package_manager_lib.h"
|
|
#include "package_manager_json.h"
|
|
#include <algorithm>
|
|
#include <deque>
|
|
#include <filesystem>
|
|
#include <fstream>
|
|
#include <nlohmann/json.hpp>
|
|
#include <set>
|
|
#include <unordered_set>
|
|
|
|
namespace fs = std::filesystem;
|
|
using json = nlohmann::json;
|
|
|
|
class DependencyResolutionTest : public ::testing::Test {
|
|
protected:
|
|
fs::path modulesDir;
|
|
fs::path uiPluginsDir;
|
|
|
|
void SetUp() override {
|
|
auto base = fs::temp_directory_path() / ("lgpm_dep_" + std::to_string(std::rand()));
|
|
modulesDir = base / "modules";
|
|
uiPluginsDir = base / "ui_plugins";
|
|
fs::create_directories(modulesDir);
|
|
fs::create_directories(uiPluginsDir);
|
|
}
|
|
|
|
void TearDown() override {
|
|
std::error_code ec;
|
|
fs::remove_all(modulesDir.parent_path(), ec);
|
|
}
|
|
|
|
void writeManifest(const fs::path& parentDir,
|
|
const std::string& name,
|
|
const std::string& type,
|
|
const std::vector<std::string>& deps = {},
|
|
const std::string& version = "1.0.0") {
|
|
fs::path dir = parentDir / name;
|
|
fs::create_directories(dir);
|
|
|
|
json manifest;
|
|
manifest["name"] = name;
|
|
manifest["type"] = type;
|
|
manifest["version"] = version;
|
|
manifest["dependencies"] = deps;
|
|
|
|
std::ofstream mf(dir / "manifest.json");
|
|
mf << manifest.dump(2);
|
|
}
|
|
|
|
// Helpers mirroring the flat name-list projections that the C ABI and
|
|
// CLI build on top of resolveDependencies / resolveDependents. Tests
|
|
// that used to exercise the library's getDependencies / getDependents
|
|
// now exercise these projections — the library no longer ships a
|
|
// parallel name-only API, but the semantics are preserved.
|
|
template <typename Node>
|
|
static std::vector<std::string> nodeNames(const std::vector<Node>& nodes) {
|
|
std::vector<std::string> out;
|
|
out.reserve(nodes.size());
|
|
for (const auto& n : nodes) out.push_back(n.name);
|
|
return out;
|
|
}
|
|
|
|
static std::vector<std::string> dependenciesFlat(PackageManagerLib& pm,
|
|
const std::string& name,
|
|
bool recursive) {
|
|
auto tree = pm.resolveDependencies(name);
|
|
if (!tree) return {};
|
|
return nodeNames(recursive ? tree->flatten() : tree->children);
|
|
}
|
|
|
|
static std::vector<std::string> dependentsFlat(PackageManagerLib& pm,
|
|
const std::string& name,
|
|
bool recursive) {
|
|
auto tree = pm.resolveDependents(name);
|
|
if (!tree) return {};
|
|
return nodeNames(recursive ? tree->flatten() : tree->children);
|
|
}
|
|
};
|
|
|
|
TEST_F(DependencyResolutionTest, ResolveLinearChain) {
|
|
// A -> B -> C
|
|
writeManifest(modulesDir, "a", "core", {"b"});
|
|
writeManifest(modulesDir, "b", "core", {"c"});
|
|
writeManifest(modulesDir, "c", "core", {});
|
|
|
|
PackageManagerLib pm;
|
|
pm.setEmbeddedModulesDirectory(modulesDir.string());
|
|
|
|
auto tree = pm.resolveDependencies("a");
|
|
ASSERT_TRUE(tree);
|
|
EXPECT_EQ(tree->name, "a");
|
|
EXPECT_EQ(tree->status, DependencyStatus::Installed);
|
|
ASSERT_EQ(tree->children.size(), 1u);
|
|
|
|
const auto& bNode = tree->children[0];
|
|
EXPECT_EQ(bNode.name, "b");
|
|
EXPECT_EQ(bNode.status, DependencyStatus::Installed);
|
|
ASSERT_EQ(bNode.children.size(), 1u);
|
|
|
|
const auto& cNode = bNode.children[0];
|
|
EXPECT_EQ(cNode.name, "c");
|
|
EXPECT_EQ(cNode.status, DependencyStatus::Installed);
|
|
EXPECT_EQ(cNode.children.size(), 0u);
|
|
}
|
|
|
|
TEST_F(DependencyResolutionTest, MissingDependencyReturnsNotInstalledLeaf) {
|
|
// A -> B (missing)
|
|
writeManifest(modulesDir, "a", "core", {"b"});
|
|
|
|
PackageManagerLib pm;
|
|
pm.setEmbeddedModulesDirectory(modulesDir.string());
|
|
|
|
auto tree = pm.resolveDependencies("a");
|
|
ASSERT_TRUE(tree);
|
|
ASSERT_EQ(tree->children.size(), 1u);
|
|
EXPECT_EQ(tree->children[0].name, "b");
|
|
EXPECT_EQ(tree->children[0].status, DependencyStatus::NotInstalled);
|
|
EXPECT_EQ(tree->children[0].children.size(), 0u);
|
|
}
|
|
|
|
TEST_F(DependencyResolutionTest, CrossCategoryDependency) {
|
|
// UI plugin depends on a core module — must find across directories.
|
|
writeManifest(uiPluginsDir, "ui_a", "ui_qml", {"core_b"});
|
|
writeManifest(modulesDir, "core_b", "core", {});
|
|
|
|
PackageManagerLib pm;
|
|
pm.setEmbeddedModulesDirectory(modulesDir.string());
|
|
pm.setEmbeddedUiPluginsDirectory(uiPluginsDir.string());
|
|
|
|
auto tree = pm.resolveDependencies("ui_a");
|
|
ASSERT_TRUE(tree);
|
|
ASSERT_EQ(tree->children.size(), 1u);
|
|
EXPECT_EQ(tree->children[0].name, "core_b");
|
|
EXPECT_EQ(tree->children[0].status, DependencyStatus::Installed);
|
|
}
|
|
|
|
TEST_F(DependencyResolutionTest, CycleIsDetectedAndMarked) {
|
|
// A -> B -> A
|
|
writeManifest(modulesDir, "a", "core", {"b"});
|
|
writeManifest(modulesDir, "b", "core", {"a"});
|
|
|
|
PackageManagerLib pm;
|
|
pm.setEmbeddedModulesDirectory(modulesDir.string());
|
|
|
|
auto tree = pm.resolveDependencies("a");
|
|
ASSERT_TRUE(tree);
|
|
ASSERT_EQ(tree->children.size(), 1u);
|
|
const auto& bNode = tree->children[0];
|
|
ASSERT_EQ(bNode.children.size(), 1u);
|
|
EXPECT_EQ(bNode.children[0].name, "a");
|
|
EXPECT_EQ(bNode.children[0].status, DependencyStatus::Cycle);
|
|
}
|
|
|
|
TEST_F(DependencyResolutionTest, UnknownRootReturnsNullopt) {
|
|
PackageManagerLib pm;
|
|
pm.setEmbeddedModulesDirectory(modulesDir.string());
|
|
EXPECT_FALSE(pm.resolveDependencies("nope").has_value());
|
|
}
|
|
|
|
TEST_F(DependencyResolutionTest, ResolveDependentsRootHasDirectChildren) {
|
|
writeManifest(modulesDir, "a", "core", {"c"});
|
|
writeManifest(modulesDir, "b", "core", {"c"});
|
|
writeManifest(modulesDir, "c", "core", {});
|
|
|
|
PackageManagerLib pm;
|
|
pm.setEmbeddedModulesDirectory(modulesDir.string());
|
|
|
|
auto tree = pm.resolveDependents("c");
|
|
ASSERT_TRUE(tree);
|
|
EXPECT_EQ(tree->name, "c");
|
|
ASSERT_EQ(tree->children.size(), 2u);
|
|
|
|
std::set<std::string> names;
|
|
for (const auto& ch : tree->children) names.insert(ch.name);
|
|
EXPECT_TRUE(names.count("a"));
|
|
EXPECT_TRUE(names.count("b"));
|
|
}
|
|
|
|
TEST_F(DependencyResolutionTest, ResolveDependentsTreeExposesTransitiveViaChildren) {
|
|
// A -> B -> C; D -> C
|
|
writeManifest(modulesDir, "a", "core", {"b"});
|
|
writeManifest(modulesDir, "b", "core", {"c"});
|
|
writeManifest(modulesDir, "c", "core", {});
|
|
writeManifest(modulesDir, "d", "core", {"c"});
|
|
|
|
PackageManagerLib pm;
|
|
pm.setEmbeddedModulesDirectory(modulesDir.string());
|
|
|
|
auto tree = pm.resolveDependents("c");
|
|
ASSERT_TRUE(tree);
|
|
|
|
// Direct dependents of c: b and d. a only reaches c transitively via b.
|
|
std::set<std::string> direct;
|
|
for (const auto& ch : tree->children) direct.insert(ch.name);
|
|
EXPECT_EQ(direct.size(), 2u);
|
|
EXPECT_TRUE(direct.count("b"));
|
|
EXPECT_TRUE(direct.count("d"));
|
|
EXPECT_FALSE(direct.count("a"));
|
|
|
|
// Flatten returns every descendant (direct + transitive), deduped.
|
|
auto all = tree->flatten();
|
|
std::set<std::string> allNames;
|
|
for (const auto& n : all) allNames.insert(n.name);
|
|
EXPECT_EQ(allNames.size(), 3u);
|
|
EXPECT_TRUE(allNames.count("a"));
|
|
EXPECT_TRUE(allNames.count("b"));
|
|
EXPECT_TRUE(allNames.count("d"));
|
|
}
|
|
|
|
TEST_F(DependencyResolutionTest, ResolveDependentsEmptyWhenNoDependents) {
|
|
writeManifest(modulesDir, "a", "core", {});
|
|
PackageManagerLib pm;
|
|
pm.setEmbeddedModulesDirectory(modulesDir.string());
|
|
auto tree = pm.resolveDependents("a");
|
|
ASSERT_TRUE(tree);
|
|
EXPECT_TRUE(tree->children.empty());
|
|
EXPECT_TRUE(tree->flatten().empty());
|
|
}
|
|
|
|
TEST_F(DependencyResolutionTest, ResolveDependentsUnknownRootReturnsNullopt) {
|
|
PackageManagerLib pm;
|
|
pm.setEmbeddedModulesDirectory(modulesDir.string());
|
|
EXPECT_FALSE(pm.resolveDependents("nope").has_value());
|
|
}
|
|
|
|
TEST_F(DependencyResolutionTest, ResolveDependentsCycleTerminatesFlatten) {
|
|
// A -> B and B -> A — reverse edges form a cycle.
|
|
writeManifest(modulesDir, "a", "core", {"b"});
|
|
writeManifest(modulesDir, "b", "core", {"a"});
|
|
|
|
PackageManagerLib pm;
|
|
pm.setEmbeddedModulesDirectory(modulesDir.string());
|
|
|
|
auto tree = pm.resolveDependents("a");
|
|
ASSERT_TRUE(tree);
|
|
EXPECT_EQ(tree->name, "a");
|
|
|
|
// A child node whose name matches the root is the cycle break — the
|
|
// builder inserts it as an empty-children leaf so descent stops.
|
|
// flatten() dedupes by name, so "b" appears exactly once; whether
|
|
// "a" re-surfaces via the cycle edge is implementation-defined
|
|
// (same latitude as DependenciesFlat_CycleSafe above). We only
|
|
// assert that the walk terminates and that "b" is present.
|
|
auto all = tree->flatten();
|
|
std::set<std::string> names;
|
|
for (const auto& n : all) names.insert(n.name);
|
|
EXPECT_TRUE(names.count("b"));
|
|
EXPECT_EQ(all.size(), names.size()); // no duplicates
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Flat name-only projections over resolveDependencies / resolveDependents.
|
|
// The library no longer ships getDependencies / getDependents; these tests
|
|
// target the name-projection helper callers build at the boundary.
|
|
// ---------------------------------------------------------------------------
|
|
|
|
TEST_F(DependencyResolutionTest, DependenciesFlat_UnknownName_ReturnsEmpty) {
|
|
PackageManagerLib pm;
|
|
pm.setEmbeddedModulesDirectory(modulesDir.string());
|
|
EXPECT_TRUE(dependenciesFlat(pm, "nope", false).empty());
|
|
EXPECT_TRUE(dependenciesFlat(pm, "nope", true).empty());
|
|
}
|
|
|
|
TEST_F(DependencyResolutionTest, DependentsFlat_UnknownName_ReturnsEmpty) {
|
|
PackageManagerLib pm;
|
|
pm.setEmbeddedModulesDirectory(modulesDir.string());
|
|
EXPECT_TRUE(dependentsFlat(pm, "nope", false).empty());
|
|
EXPECT_TRUE(dependentsFlat(pm, "nope", true).empty());
|
|
}
|
|
|
|
TEST_F(DependencyResolutionTest, DependenciesFlat_NoDeps_ReturnsEmpty) {
|
|
writeManifest(modulesDir, "a", "core", {});
|
|
PackageManagerLib pm;
|
|
pm.setEmbeddedModulesDirectory(modulesDir.string());
|
|
EXPECT_TRUE(dependenciesFlat(pm, "a", false).empty());
|
|
EXPECT_TRUE(dependenciesFlat(pm, "a", true).empty());
|
|
}
|
|
|
|
TEST_F(DependencyResolutionTest, DependenciesFlat_DirectVsRecursive) {
|
|
// a -> b -> c
|
|
writeManifest(modulesDir, "a", "core", {"b"});
|
|
writeManifest(modulesDir, "b", "core", {"c"});
|
|
writeManifest(modulesDir, "c", "core", {});
|
|
|
|
PackageManagerLib pm;
|
|
pm.setEmbeddedModulesDirectory(modulesDir.string());
|
|
|
|
auto direct = dependenciesFlat(pm, "a", false);
|
|
ASSERT_EQ(direct.size(), 1u);
|
|
EXPECT_EQ(direct[0], "b");
|
|
|
|
auto transitive = dependenciesFlat(pm, "a", true);
|
|
std::set<std::string> asSet(transitive.begin(), transitive.end());
|
|
EXPECT_EQ(asSet.size(), 2u);
|
|
EXPECT_TRUE(asSet.count("b"));
|
|
EXPECT_TRUE(asSet.count("c"));
|
|
}
|
|
|
|
TEST_F(DependencyResolutionTest, DependentsFlat_DirectVsRecursive) {
|
|
// a -> b -> c; d -> c
|
|
writeManifest(modulesDir, "a", "core", {"b"});
|
|
writeManifest(modulesDir, "b", "core", {"c"});
|
|
writeManifest(modulesDir, "c", "core", {});
|
|
writeManifest(modulesDir, "d", "core", {"c"});
|
|
|
|
PackageManagerLib pm;
|
|
pm.setEmbeddedModulesDirectory(modulesDir.string());
|
|
|
|
auto direct = dependentsFlat(pm, "c", false);
|
|
std::set<std::string> directSet(direct.begin(), direct.end());
|
|
EXPECT_EQ(directSet.size(), 2u);
|
|
EXPECT_TRUE(directSet.count("b"));
|
|
EXPECT_TRUE(directSet.count("d"));
|
|
EXPECT_FALSE(directSet.count("a")); // a is only a transitive dependent
|
|
|
|
auto transitive = dependentsFlat(pm, "c", true);
|
|
std::set<std::string> transitiveSet(transitive.begin(), transitive.end());
|
|
EXPECT_EQ(transitiveSet.size(), 3u);
|
|
EXPECT_TRUE(transitiveSet.count("a"));
|
|
EXPECT_TRUE(transitiveSet.count("b"));
|
|
EXPECT_TRUE(transitiveSet.count("d"));
|
|
}
|
|
|
|
TEST_F(DependencyResolutionTest, DependenciesFlat_DiamondRecursiveDedups) {
|
|
// a -> b, a -> c, b -> d, c -> d
|
|
writeManifest(modulesDir, "a", "core", {"b", "c"});
|
|
writeManifest(modulesDir, "b", "core", {"d"});
|
|
writeManifest(modulesDir, "c", "core", {"d"});
|
|
writeManifest(modulesDir, "d", "core", {});
|
|
|
|
PackageManagerLib pm;
|
|
pm.setEmbeddedModulesDirectory(modulesDir.string());
|
|
|
|
auto deps = dependenciesFlat(pm, "a", true);
|
|
std::set<std::string> asSet(deps.begin(), deps.end());
|
|
EXPECT_EQ(deps.size(), asSet.size()); // no duplicates
|
|
EXPECT_EQ(asSet.size(), 3u);
|
|
EXPECT_TRUE(asSet.count("b"));
|
|
EXPECT_TRUE(asSet.count("c"));
|
|
EXPECT_TRUE(asSet.count("d"));
|
|
}
|
|
|
|
TEST_F(DependencyResolutionTest, DependentsFlat_DiamondRecursiveDedups) {
|
|
// a -> b, a -> c, b -> d, c -> d
|
|
writeManifest(modulesDir, "a", "core", {"b", "c"});
|
|
writeManifest(modulesDir, "b", "core", {"d"});
|
|
writeManifest(modulesDir, "c", "core", {"d"});
|
|
writeManifest(modulesDir, "d", "core", {});
|
|
|
|
PackageManagerLib pm;
|
|
pm.setEmbeddedModulesDirectory(modulesDir.string());
|
|
|
|
auto dependents = dependentsFlat(pm, "d", true);
|
|
std::set<std::string> asSet(dependents.begin(), dependents.end());
|
|
EXPECT_EQ(dependents.size(), asSet.size()); // no duplicates
|
|
EXPECT_EQ(asSet.size(), 3u);
|
|
EXPECT_TRUE(asSet.count("a"));
|
|
EXPECT_TRUE(asSet.count("b"));
|
|
EXPECT_TRUE(asSet.count("c"));
|
|
}
|
|
|
|
TEST_F(DependencyResolutionTest, DependenciesFlat_CycleSafe) {
|
|
// a -> b -> a (cycle)
|
|
writeManifest(modulesDir, "a", "core", {"b"});
|
|
writeManifest(modulesDir, "b", "core", {"a"});
|
|
|
|
PackageManagerLib pm;
|
|
pm.setEmbeddedModulesDirectory(modulesDir.string());
|
|
|
|
auto deps = dependenciesFlat(pm, "a", true);
|
|
std::set<std::string> asSet(deps.begin(), deps.end());
|
|
// The flat walk starts from a's children and dedups — b appears, and
|
|
// the cycle back to a is pruned (either because the resolver marks
|
|
// that node as Cycle and we still include its name in the flat list,
|
|
// or the seen-set filters it out; either way we end up with b and
|
|
// optionally a from the cycle edge).
|
|
EXPECT_TRUE(asSet.count("b"));
|
|
}
|
|
|
|
TEST_F(DependencyResolutionTest, DependenciesFlat_NonRecursiveIncludesUninstalled) {
|
|
// a -> missing (declared but not installed)
|
|
writeManifest(modulesDir, "a", "core", {"missing"});
|
|
PackageManagerLib pm;
|
|
pm.setEmbeddedModulesDirectory(modulesDir.string());
|
|
|
|
auto deps = dependenciesFlat(pm, "a", false);
|
|
ASSERT_EQ(deps.size(), 1u);
|
|
EXPECT_EQ(deps[0], "missing");
|
|
|
|
// Recursive also lists "missing" but can't expand it further.
|
|
auto transitive = dependenciesFlat(pm, "a", true);
|
|
ASSERT_EQ(transitive.size(), 1u);
|
|
EXPECT_EQ(transitive[0], "missing");
|
|
}
|
|
|
|
TEST_F(DependencyResolutionTest, DependenciesFlat_CrossCategory) {
|
|
// ui_a depends on core_b — must resolve across dir categories.
|
|
writeManifest(uiPluginsDir, "ui_a", "ui_qml", {"core_b"});
|
|
writeManifest(modulesDir, "core_b", "core", {});
|
|
|
|
PackageManagerLib pm;
|
|
pm.setEmbeddedModulesDirectory(modulesDir.string());
|
|
pm.setEmbeddedUiPluginsDirectory(uiPluginsDir.string());
|
|
|
|
auto deps = dependenciesFlat(pm, "ui_a", false);
|
|
ASSERT_EQ(deps.size(), 1u);
|
|
EXPECT_EQ(deps[0], "core_b");
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Object-form dependency entries.
|
|
//
|
|
// The LGX spec (logos-package docs/spec.md, "Dependency entries") allows each
|
|
// element of `dependencies[]` to be either a plain string or an object
|
|
// {name, version?, signer?}. The scan used to accept only the string form with
|
|
// no else branch, so an object entry lost THE EDGE ITSELF: it vanished from
|
|
// resolveDependencies, from resolveDependents (an uninstall stopped warning
|
|
// about a dependent that declared its need in object form), and from the
|
|
// installed-package `dependencies` array basecamp derives its missing-dep
|
|
// marker from. No workspace manifest uses the object form, which is why
|
|
// nothing caught it — so these tests write the object form directly.
|
|
// ---------------------------------------------------------------------------
|
|
|
|
// Writes a manifest whose `dependencies` array is supplied verbatim, so a test
|
|
// can mix plain-string and object entries exactly as an .lgx manifest may.
|
|
static void writeManifestRawDeps(const fs::path& parentDir,
|
|
const std::string& name,
|
|
const json& deps,
|
|
const std::string& version = "1.0.0") {
|
|
fs::path dir = parentDir / name;
|
|
fs::create_directories(dir);
|
|
json manifest;
|
|
manifest["name"] = name;
|
|
manifest["type"] = "core";
|
|
manifest["version"] = version;
|
|
manifest["dependencies"] = deps;
|
|
std::ofstream mf(dir / "manifest.json");
|
|
mf << manifest.dump(2);
|
|
}
|
|
|
|
TEST_F(DependencyResolutionTest, ObjectFormDependencyKeepsForwardEdge) {
|
|
writeManifestRawDeps(modulesDir, "app",
|
|
json::array({ json{{"name", "lib"}, {"version", "^1.2.0"}} }));
|
|
writeManifest(modulesDir, "lib", "core", {}, "1.5.0");
|
|
|
|
PackageManagerLib pm;
|
|
pm.setEmbeddedModulesDirectory(modulesDir.string());
|
|
|
|
auto tree = pm.resolveDependencies("app");
|
|
ASSERT_TRUE(tree);
|
|
ASSERT_EQ(tree->children.size(), 1u);
|
|
EXPECT_EQ(tree->children[0].name, "lib");
|
|
EXPECT_EQ(tree->children[0].status, DependencyStatus::Installed);
|
|
}
|
|
|
|
TEST_F(DependencyResolutionTest, ObjectFormDependencyKeepsReverseEdge) {
|
|
// The uninstall-safety direction: removing "lib" must still report "app".
|
|
writeManifestRawDeps(modulesDir, "app",
|
|
json::array({ json{{"name", "lib"}, {"version", "^1.2.0"}} }));
|
|
writeManifest(modulesDir, "lib", "core", {}, "1.5.0");
|
|
|
|
PackageManagerLib pm;
|
|
pm.setEmbeddedModulesDirectory(modulesDir.string());
|
|
|
|
auto tree = pm.resolveDependents("lib");
|
|
ASSERT_TRUE(tree);
|
|
ASSERT_EQ(tree->children.size(), 1u);
|
|
EXPECT_EQ(tree->children[0].name, "app");
|
|
}
|
|
|
|
TEST_F(DependencyResolutionTest, ObjectFormMissingDependencyIsStillReportedMissing) {
|
|
// The basecamp red-cross path: an object-form dep that is NOT installed
|
|
// must surface as not_installed, not as "no dependencies at all".
|
|
writeManifestRawDeps(modulesDir, "app",
|
|
json::array({ json{{"name", "absent"}, {"version", "^2.0.0"}} }));
|
|
|
|
PackageManagerLib pm;
|
|
pm.setEmbeddedModulesDirectory(modulesDir.string());
|
|
|
|
auto tree = pm.resolveDependencies("app");
|
|
ASSERT_TRUE(tree);
|
|
ASSERT_EQ(tree->children.size(), 1u);
|
|
EXPECT_EQ(tree->children[0].name, "absent");
|
|
EXPECT_EQ(tree->children[0].status, DependencyStatus::NotInstalled);
|
|
}
|
|
|
|
TEST_F(DependencyResolutionTest, MixedStringAndObjectDependenciesBothResolve) {
|
|
writeManifestRawDeps(modulesDir, "app",
|
|
json::array({ "plain",
|
|
json{{"name", "ranged"}, {"version", ">=1.0.0"}},
|
|
json{{"name", "pinned"},
|
|
{"signer", "did:jwk:eyJrdHkiOiJPS1AifQ"}} }));
|
|
writeManifest(modulesDir, "plain", "core", {});
|
|
writeManifest(modulesDir, "ranged", "core", {});
|
|
writeManifest(modulesDir, "pinned", "core", {});
|
|
|
|
PackageManagerLib pm;
|
|
pm.setEmbeddedModulesDirectory(modulesDir.string());
|
|
|
|
auto deps = dependenciesFlat(pm, "app", false);
|
|
std::set<std::string> asSet(deps.begin(), deps.end());
|
|
EXPECT_EQ(asSet.size(), 3u);
|
|
EXPECT_TRUE(asSet.count("plain"));
|
|
EXPECT_TRUE(asSet.count("ranged"));
|
|
EXPECT_TRUE(asSet.count("pinned"));
|
|
}
|
|
|
|
TEST_F(DependencyResolutionTest, ObjectFormConstraintsAreCarriedNotJustTheName) {
|
|
// The name alone restores the graph; the range and signer are what make the
|
|
// constraint reachable to anything that evaluates it.
|
|
writeManifestRawDeps(modulesDir, "app",
|
|
json::array({ "plain",
|
|
json{{"name", "ranged"}, {"version", "^1.2.0"}},
|
|
json{{"name", "pinned"},
|
|
{"version", ">=0.5.0"},
|
|
{"signer", "did:jwk:eyJrdHkiOiJPS1AifQ"}} }));
|
|
|
|
PackageManagerLib pm;
|
|
pm.setEmbeddedModulesDirectory(modulesDir.string());
|
|
|
|
auto pkgs = pm.getInstalledPackages();
|
|
auto it = std::find_if(pkgs.begin(), pkgs.end(),
|
|
[](const InstalledPackage& p) { return p.name == "app"; });
|
|
ASSERT_NE(it, pkgs.end());
|
|
|
|
// Every edge is present in the name list, in declared order.
|
|
ASSERT_EQ(it->dependencies.size(), 3u);
|
|
EXPECT_EQ(it->dependencies[0], "plain");
|
|
EXPECT_EQ(it->dependencies[1], "ranged");
|
|
EXPECT_EQ(it->dependencies[2], "pinned");
|
|
|
|
// Only the constrained entries appear in the constraint list.
|
|
ASSERT_EQ(it->dependencyConstraints.size(), 2u);
|
|
EXPECT_EQ(it->dependencyConstraints[0].name, "ranged");
|
|
ASSERT_TRUE(it->dependencyConstraints[0].version.has_value());
|
|
EXPECT_EQ(*it->dependencyConstraints[0].version, "^1.2.0");
|
|
EXPECT_FALSE(it->dependencyConstraints[0].signer.has_value());
|
|
|
|
EXPECT_EQ(it->dependencyConstraints[1].name, "pinned");
|
|
ASSERT_TRUE(it->dependencyConstraints[1].version.has_value());
|
|
EXPECT_EQ(*it->dependencyConstraints[1].version, ">=0.5.0");
|
|
ASSERT_TRUE(it->dependencyConstraints[1].signer.has_value());
|
|
EXPECT_EQ(*it->dependencyConstraints[1].signer, "did:jwk:eyJrdHkiOiJPS1AifQ");
|
|
}
|
|
|
|
TEST_F(DependencyResolutionTest, PlainDependenciesCarryNoConstraints) {
|
|
writeManifest(modulesDir, "app", "core", {"lib"});
|
|
|
|
PackageManagerLib pm;
|
|
pm.setEmbeddedModulesDirectory(modulesDir.string());
|
|
|
|
auto pkgs = pm.getInstalledPackages();
|
|
auto it = std::find_if(pkgs.begin(), pkgs.end(),
|
|
[](const InstalledPackage& p) { return p.name == "app"; });
|
|
ASSERT_NE(it, pkgs.end());
|
|
ASSERT_EQ(it->dependencies.size(), 1u);
|
|
EXPECT_EQ(it->dependencies[0], "lib");
|
|
EXPECT_TRUE(it->dependencyConstraints.empty());
|
|
}
|
|
|
|
TEST_F(DependencyResolutionTest, MalformedDependencyEntriesDeclareNoEdge) {
|
|
// A number, a null, and an object without a string `name` are not
|
|
// dependency entries in either form — they must not synthesise an edge.
|
|
writeManifestRawDeps(modulesDir, "app",
|
|
json::array({ 42, nullptr, json::object(),
|
|
json{{"name", 7}}, "", json{{"name", ""}},
|
|
"real" }));
|
|
writeManifest(modulesDir, "real", "core", {});
|
|
|
|
PackageManagerLib pm;
|
|
pm.setEmbeddedModulesDirectory(modulesDir.string());
|
|
|
|
auto deps = dependenciesFlat(pm, "app", false);
|
|
ASSERT_EQ(deps.size(), 1u);
|
|
EXPECT_EQ(deps[0], "real");
|
|
}
|
|
|
|
TEST_F(DependencyResolutionTest, MalformedDependencyEntryWarnsOnStderr) {
|
|
// A dropped edge with no diagnostic is the failure mode this whole change
|
|
// is about, so the one remaining drop — a malformed entry — must announce
|
|
// itself. Without the warning, a manifest that bypasses lgx::Manifest
|
|
// validation (an embedded install written at build time, or a hand-edited
|
|
// manifest.json) loses a dependency and nothing anywhere says so.
|
|
writeManifestRawDeps(modulesDir, "app",
|
|
json::array({ json{{"name", 7}}, "good" }));
|
|
writeManifest(modulesDir, "good", "core", {});
|
|
|
|
PackageManagerLib pm;
|
|
pm.setEmbeddedModulesDirectory(modulesDir.string());
|
|
|
|
testing::internal::CaptureStderr();
|
|
auto deps = dependenciesFlat(pm, "app", false);
|
|
std::string err = testing::internal::GetCapturedStderr();
|
|
|
|
// The good edge still resolves...
|
|
ASSERT_EQ(deps.size(), 1u);
|
|
EXPECT_EQ(deps[0], "good");
|
|
|
|
// ...and the bad one is reported rather than dropped in silence. The
|
|
// message must name the package and the offending entry, because the
|
|
// manifest that produced it is not the one the user is looking at.
|
|
EXPECT_NE(err.find("malformed dependencies[] entry"), std::string::npos) << err;
|
|
EXPECT_NE(err.find("app"), std::string::npos) << err;
|
|
EXPECT_NE(err.find("\"name\":7"), std::string::npos) << err;
|
|
}
|
|
|
|
TEST_F(DependencyResolutionTest, WellFormedDependenciesWarnAboutNothing) {
|
|
// Invariance guard: the warning must fire ONLY for entries that declare no
|
|
// edge. A manifest mixing both legal forms has to stay silent, or the
|
|
// diagnostic becomes noise and gets muted.
|
|
writeManifestRawDeps(modulesDir, "app",
|
|
json::array({ "plain",
|
|
json{{"name", "ranged"}, {"version", "^1.2.0"}} }));
|
|
writeManifest(modulesDir, "plain", "core", {});
|
|
writeManifest(modulesDir, "ranged", "core", {});
|
|
|
|
PackageManagerLib pm;
|
|
pm.setEmbeddedModulesDirectory(modulesDir.string());
|
|
|
|
testing::internal::CaptureStderr();
|
|
(void)dependenciesFlat(pm, "app", false);
|
|
std::string err = testing::internal::GetCapturedStderr();
|
|
|
|
// Deliberately asserts the warning ONLY, with no edge-count assertion, so
|
|
// this holds identically before and after the fix — a guard that only
|
|
// starts holding once the fix lands is not a guard. That the two entries
|
|
// actually resolve to two edges is MixedStringAndObjectDependenciesBothResolve's
|
|
// job.
|
|
EXPECT_EQ(err.find("malformed dependencies[] entry"), std::string::npos) << err;
|
|
}
|
|
|
|
// Constraint EVALUATION. The block above proves the range survives the scan;
|
|
// these prove something compares it against the installed version.
|
|
|
|
TEST_F(DependencyResolutionTest, UnsatisfiedRangeReportsVersionMismatch) {
|
|
writeManifestRawDeps(modulesDir, "app",
|
|
json::array({ json{{"name", "lib"}, {"version", "^2.0.0"}} }));
|
|
writeManifest(modulesDir, "lib", "core", {}, "1.0.0");
|
|
|
|
PackageManagerLib pm;
|
|
pm.setEmbeddedModulesDirectory(modulesDir.string());
|
|
|
|
auto tree = pm.resolveDependencies("app");
|
|
ASSERT_TRUE(tree);
|
|
ASSERT_EQ(tree->children.size(), 1u);
|
|
const auto& dep = tree->children[0];
|
|
EXPECT_EQ(dep.name, "lib");
|
|
EXPECT_EQ(dep.status, DependencyStatus::VersionMismatch);
|
|
EXPECT_STREQ(dependencyStatusToString(dep.status), "version_mismatch");
|
|
}
|
|
|
|
TEST_F(DependencyResolutionTest, SatisfiedRangeStaysInstalled) {
|
|
// Control: same shape, a version inside the range.
|
|
writeManifestRawDeps(modulesDir, "app",
|
|
json::array({ json{{"name", "lib"}, {"version", "^2.0.0"}} }));
|
|
writeManifest(modulesDir, "lib", "core", {}, "2.1.0");
|
|
|
|
PackageManagerLib pm;
|
|
pm.setEmbeddedModulesDirectory(modulesDir.string());
|
|
|
|
auto tree = pm.resolveDependencies("app");
|
|
ASSERT_TRUE(tree);
|
|
ASSERT_EQ(tree->children.size(), 1u);
|
|
EXPECT_EQ(tree->children[0].status, DependencyStatus::Installed);
|
|
}
|
|
|
|
TEST_F(DependencyResolutionTest, UnconstrainedDependencyIsNeverAMismatch) {
|
|
// Every package in the fleet is bare-name today, and whatever is installed
|
|
// satisfies "no range" — so the new status must be unreachable for them.
|
|
writeManifest(modulesDir, "app", "core", {"lib"});
|
|
writeManifest(modulesDir, "lib", "core", {}, "0.0.1");
|
|
|
|
PackageManagerLib pm;
|
|
pm.setEmbeddedModulesDirectory(modulesDir.string());
|
|
|
|
auto tree = pm.resolveDependencies("app");
|
|
ASSERT_TRUE(tree);
|
|
ASSERT_EQ(tree->children.size(), 1u);
|
|
EXPECT_EQ(tree->children[0].status, DependencyStatus::Installed);
|
|
EXPECT_FALSE(tree->children[0].requiredVersion.has_value());
|
|
}
|
|
|
|
TEST_F(DependencyResolutionTest, AbsenceOutranksVersionMismatch) {
|
|
// Absent + constrained reports absence: there is no version to judge it
|
|
// against. The range still rides along, so a caller knows which to install.
|
|
writeManifestRawDeps(modulesDir, "app",
|
|
json::array({ json{{"name", "absent"}, {"version", "^2.0.0"}} }));
|
|
|
|
PackageManagerLib pm;
|
|
pm.setEmbeddedModulesDirectory(modulesDir.string());
|
|
|
|
auto tree = pm.resolveDependencies("app");
|
|
ASSERT_TRUE(tree);
|
|
ASSERT_EQ(tree->children.size(), 1u);
|
|
EXPECT_EQ(tree->children[0].status, DependencyStatus::NotInstalled);
|
|
ASSERT_TRUE(tree->children[0].requiredVersion.has_value());
|
|
EXPECT_EQ(*tree->children[0].requiredVersion, "^2.0.0");
|
|
}
|
|
|
|
TEST_F(DependencyResolutionTest, MismatchedNodeCarriesBothVersions) {
|
|
// "Needs ^2.0.0, have 1.4.2" takes both: the installed version comes from
|
|
// the node, the required range from the edge.
|
|
writeManifestRawDeps(modulesDir, "app",
|
|
json::array({ json{{"name", "lib"}, {"version", "^2.0.0"}} }));
|
|
writeManifest(modulesDir, "lib", "core", {}, "1.4.2");
|
|
|
|
PackageManagerLib pm;
|
|
pm.setEmbeddedModulesDirectory(modulesDir.string());
|
|
|
|
auto tree = pm.resolveDependencies("app");
|
|
ASSERT_TRUE(tree);
|
|
ASSERT_EQ(tree->children.size(), 1u);
|
|
const auto& dep = tree->children[0];
|
|
EXPECT_EQ(dep.status, DependencyStatus::VersionMismatch);
|
|
EXPECT_EQ(dep.version, "1.4.2");
|
|
EXPECT_EQ(dep.installType, InstallType::Embedded);
|
|
ASSERT_TRUE(dep.requiredVersion.has_value());
|
|
EXPECT_EQ(*dep.requiredVersion, "^2.0.0");
|
|
}
|
|
|
|
TEST_F(DependencyResolutionTest, SignerIsEvaluatedWhenThePinIsPresent) {
|
|
// A `signer` pin asks WHICH `lib` this is — an identity question, so this
|
|
// walk answers it. Whether a publisher may be installed at all is
|
|
// authorization and stays with the trust-anchor gate in installPluginFile,
|
|
// which is why this walk consults no keyring. Nothing here records who
|
|
// published `lib`, hence signer_unknown; see UnknownSignerPolicy and
|
|
// test_observed_signer.cpp.
|
|
writeManifestRawDeps(modulesDir, "app",
|
|
json::array({ json{{"name", "lib"},
|
|
{"signer", "did:jwk:eyJrdHkiOiJPS1AifQ"}} }));
|
|
writeManifest(modulesDir, "lib", "core", {}, "1.0.0");
|
|
|
|
PackageManagerLib pm;
|
|
pm.setEmbeddedModulesDirectory(modulesDir.string());
|
|
|
|
auto tree = pm.resolveDependencies("app");
|
|
ASSERT_TRUE(tree);
|
|
ASSERT_EQ(tree->children.size(), 1u);
|
|
const auto& dep = tree->children[0];
|
|
EXPECT_EQ(dep.status, DependencyStatus::SignerUnknown);
|
|
ASSERT_TRUE(dep.requiredSigner.has_value());
|
|
EXPECT_EQ(*dep.requiredSigner, "did:jwk:eyJrdHkiOiJPS1AifQ");
|
|
EXPECT_FALSE(dep.requiredVersion.has_value());
|
|
// An unknown publisher is not an absent package.
|
|
EXPECT_EQ(dep.version, "1.0.0");
|
|
}
|
|
|
|
TEST_F(DependencyResolutionTest, UnparseableRangeIsUnsatisfied) {
|
|
// Fail CLOSED: dropping a range we cannot parse turns a typo into a
|
|
// silently disabled check.
|
|
writeManifestRawDeps(modulesDir, "app",
|
|
json::array({ json{{"name", "lib"}, {"version", "not-a-range"}} }));
|
|
writeManifest(modulesDir, "lib", "core", {}, "1.0.0");
|
|
|
|
PackageManagerLib pm;
|
|
pm.setEmbeddedModulesDirectory(modulesDir.string());
|
|
|
|
auto tree = pm.resolveDependencies("app");
|
|
ASSERT_TRUE(tree);
|
|
ASSERT_EQ(tree->children.size(), 1u);
|
|
EXPECT_EQ(tree->children[0].status, DependencyStatus::VersionMismatch);
|
|
}
|
|
|
|
TEST_F(DependencyResolutionTest, VersionMismatchIsFoundDeepInTheTree) {
|
|
// The constraint belongs to an EDGE, so it must survive recursion:
|
|
// a -> b -> c, only b constrains c.
|
|
writeManifest(modulesDir, "a", "core", {"b"});
|
|
writeManifestRawDeps(modulesDir, "b",
|
|
json::array({ json{{"name", "c"}, {"version", ">=3.0.0"}} }));
|
|
writeManifest(modulesDir, "c", "core", {}, "2.9.9");
|
|
|
|
PackageManagerLib pm;
|
|
pm.setEmbeddedModulesDirectory(modulesDir.string());
|
|
|
|
auto tree = pm.resolveDependencies("a");
|
|
ASSERT_TRUE(tree);
|
|
EXPECT_EQ(tree->status, DependencyStatus::Installed);
|
|
ASSERT_EQ(tree->children.size(), 1u);
|
|
EXPECT_EQ(tree->children[0].status, DependencyStatus::Installed);
|
|
ASSERT_EQ(tree->children[0].children.size(), 1u);
|
|
const auto& c = tree->children[0].children[0];
|
|
EXPECT_EQ(c.name, "c");
|
|
EXPECT_EQ(c.status, DependencyStatus::VersionMismatch);
|
|
}
|
|
|
|
TEST_F(DependencyResolutionTest, VersionMismatchSurvivesFlatten) {
|
|
// flatten() is what the C ABI and every flat-list consumer read; a status
|
|
// that only exists on the tree would never reach them.
|
|
writeManifest(modulesDir, "a", "core", {"b"});
|
|
writeManifestRawDeps(modulesDir, "b",
|
|
json::array({ json{{"name", "c"}, {"version", ">=3.0.0"}} }));
|
|
writeManifest(modulesDir, "c", "core", {}, "2.9.9");
|
|
|
|
PackageManagerLib pm;
|
|
pm.setEmbeddedModulesDirectory(modulesDir.string());
|
|
|
|
auto tree = pm.resolveDependencies("a");
|
|
ASSERT_TRUE(tree);
|
|
auto flat = tree->flatten();
|
|
auto it = std::find_if(flat.begin(), flat.end(),
|
|
[](const DependencyTreeNode& n) { return n.name == "c"; });
|
|
ASSERT_NE(it, flat.end());
|
|
EXPECT_EQ(it->status, DependencyStatus::VersionMismatch);
|
|
ASSERT_TRUE(it->requiredVersion.has_value());
|
|
EXPECT_EQ(*it->requiredVersion, ">=3.0.0");
|
|
}
|
|
|
|
TEST_F(DependencyResolutionTest, DeeperMismatchIsNotMaskedByAShallowerBareEdge) {
|
|
// The DIAMOND: "lib" is named twice — by the root with no range, by
|
|
// "helper" with one it does not satisfy. flatten() dedupes by name
|
|
// FIRST-WINS and BFS reaches the depth-1 bare edge first, so the
|
|
// unsatisfied edge is the one at risk of vanishing from the flat list, the
|
|
// only projection resolveFlatDependencies and basecamp's load gate read.
|
|
writeManifest(modulesDir, "app", "core", {"lib", "helper"});
|
|
writeManifestRawDeps(modulesDir, "helper",
|
|
json::array({ json{{"name", "lib"}, {"version", "^2.0.0"}} }));
|
|
writeManifest(modulesDir, "lib", "core", {}, "1.0.0");
|
|
|
|
PackageManagerLib pm;
|
|
pm.setEmbeddedModulesDirectory(modulesDir.string());
|
|
|
|
auto tree = pm.resolveDependencies("app");
|
|
ASSERT_TRUE(tree);
|
|
|
|
// The resolver itself is right — the mismatch IS on the tree.
|
|
ASSERT_EQ(tree->children.size(), 2u);
|
|
const auto& helper = tree->children[1];
|
|
ASSERT_EQ(helper.name, "helper");
|
|
ASSERT_EQ(helper.children.size(), 1u);
|
|
ASSERT_EQ(helper.children[0].status, DependencyStatus::VersionMismatch);
|
|
|
|
// One row per package still, but it must report the UNsatisfied edge: a
|
|
// package satisfies its dependants only if it satisfies all of them.
|
|
auto flat = tree->flatten();
|
|
EXPECT_EQ(flat.size(), 2u);
|
|
auto it = std::find_if(flat.begin(), flat.end(),
|
|
[](const DependencyTreeNode& n) { return n.name == "lib"; });
|
|
ASSERT_NE(it, flat.end());
|
|
EXPECT_EQ(it->status, DependencyStatus::VersionMismatch);
|
|
ASSERT_TRUE(it->requiredVersion.has_value());
|
|
EXPECT_EQ(*it->requiredVersion, "^2.0.0");
|
|
// The installed version stays on the row: "requires ^2.0.0, found 1.0.0".
|
|
EXPECT_EQ(it->version, "1.0.0");
|
|
}
|
|
|
|
TEST_F(DependencyResolutionTest, DeeperMismatchSurvivesRegardlessOfDeclarationOrder) {
|
|
// Same graph, the root's two entries swapped: BFS visits by DEPTH, so
|
|
// declaration order must not decide whether the mismatch is reported.
|
|
writeManifest(modulesDir, "app", "core", {"helper", "lib"});
|
|
writeManifestRawDeps(modulesDir, "helper",
|
|
json::array({ json{{"name", "lib"}, {"version", "^2.0.0"}} }));
|
|
writeManifest(modulesDir, "lib", "core", {}, "1.0.0");
|
|
|
|
PackageManagerLib pm;
|
|
pm.setEmbeddedModulesDirectory(modulesDir.string());
|
|
|
|
auto tree = pm.resolveDependencies("app");
|
|
ASSERT_TRUE(tree);
|
|
auto flat = tree->flatten();
|
|
auto it = std::find_if(flat.begin(), flat.end(),
|
|
[](const DependencyTreeNode& n) { return n.name == "lib"; });
|
|
ASSERT_NE(it, flat.end());
|
|
EXPECT_EQ(it->status, DependencyStatus::VersionMismatch);
|
|
}
|
|
|
|
TEST_F(DependencyResolutionTest, ASatisfiedDiamondStaysInstalledAndDeduped) {
|
|
// Control against "any duplicate is a mismatch": both edges satisfied, so
|
|
// one row, still installed, reporting the first edge's constraint.
|
|
writeManifest(modulesDir, "app", "core", {"lib", "helper"});
|
|
writeManifestRawDeps(modulesDir, "helper",
|
|
json::array({ json{{"name", "lib"}, {"version", "^1.0.0"}} }));
|
|
writeManifest(modulesDir, "lib", "core", {}, "1.0.0");
|
|
|
|
PackageManagerLib pm;
|
|
pm.setEmbeddedModulesDirectory(modulesDir.string());
|
|
|
|
auto tree = pm.resolveDependencies("app");
|
|
ASSERT_TRUE(tree);
|
|
auto flat = tree->flatten();
|
|
EXPECT_EQ(flat.size(), 2u);
|
|
auto it = std::find_if(flat.begin(), flat.end(),
|
|
[](const DependencyTreeNode& n) { return n.name == "lib"; });
|
|
ASSERT_NE(it, flat.end());
|
|
EXPECT_EQ(it->status, DependencyStatus::Installed);
|
|
EXPECT_FALSE(it->requiredVersion.has_value()); // the depth-1 bare edge
|
|
}
|
|
|
|
TEST_F(DependencyResolutionTest, RootCarriesNoConstraint) {
|
|
// Nothing points AT the root, so it has no edge and no range to judge.
|
|
writeManifestRawDeps(modulesDir, "app",
|
|
json::array({ json{{"name", "lib"}, {"version", "^2.0.0"}} }));
|
|
writeManifest(modulesDir, "lib", "core", {}, "1.0.0");
|
|
|
|
PackageManagerLib pm;
|
|
pm.setEmbeddedModulesDirectory(modulesDir.string());
|
|
|
|
auto tree = pm.resolveDependencies("app");
|
|
ASSERT_TRUE(tree);
|
|
EXPECT_EQ(tree->status, DependencyStatus::Installed);
|
|
EXPECT_FALSE(tree->requiredVersion.has_value());
|
|
EXPECT_FALSE(tree->requiredSigner.has_value());
|
|
}
|
|
|
|
// The `lgpm --json` wire format. The two new keys must be ADDITIVE: a
|
|
// bare-name tree has to serialise exactly as it did before.
|
|
|
|
TEST_F(DependencyResolutionTest, JsonOmitsConstraintKeysForBareNameDependencies) {
|
|
writeManifest(modulesDir, "app", "core", {"lib"});
|
|
writeManifest(modulesDir, "lib", "core", {}, "1.0.0");
|
|
|
|
PackageManagerLib pm;
|
|
pm.setEmbeddedModulesDirectory(modulesDir.string());
|
|
|
|
auto tree = pm.resolveDependencies("app");
|
|
ASSERT_TRUE(tree);
|
|
json j = *tree;
|
|
ASSERT_EQ(j["children"].size(), 1u);
|
|
const json& dep = j["children"][0];
|
|
EXPECT_EQ(dep["status"], "installed");
|
|
EXPECT_FALSE(dep.contains("requiredVersion"));
|
|
EXPECT_FALSE(dep.contains("requiredSigner"));
|
|
}
|
|
|
|
TEST_F(DependencyResolutionTest, JsonReportsMismatchWithBothVersions) {
|
|
writeManifestRawDeps(modulesDir, "app",
|
|
json::array({ json{{"name", "lib"},
|
|
{"version", "^2.0.0"},
|
|
{"signer", "did:jwk:eyJrdHkiOiJPS1AifQ"}} }));
|
|
writeManifest(modulesDir, "lib", "core", {}, "1.0.0");
|
|
|
|
PackageManagerLib pm;
|
|
pm.setEmbeddedModulesDirectory(modulesDir.string());
|
|
|
|
auto tree = pm.resolveDependencies("app");
|
|
ASSERT_TRUE(tree);
|
|
json j = *tree;
|
|
ASSERT_EQ(j["children"].size(), 1u);
|
|
const json& dep = j["children"][0];
|
|
EXPECT_EQ(dep["status"], "version_mismatch");
|
|
// Installed, so it keeps the fields an installed node has — unlike
|
|
// not_installed and cycle, which blank them.
|
|
EXPECT_EQ(dep["version"], "1.0.0");
|
|
EXPECT_EQ(dep["installType"], "embedded");
|
|
EXPECT_EQ(dep["requiredVersion"], "^2.0.0");
|
|
EXPECT_EQ(dep["requiredSigner"], "did:jwk:eyJrdHkiOiJPS1AifQ");
|
|
}
|