|
@@ -256,6 +256,253 @@ namespace Usm.Tests {
|
|
|
check(pre_upgrade.size == 0, "a pre-upgrade entry without size materialises as 0");
|
|
check(pre_upgrade.size == 0, "a pre-upgrade entry without size materialises as 0");
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+ // ---- DCBA artifact listings --------------------------------------------------
|
|
|
|
|
+
|
|
|
|
|
+ /** Streams {@link lines} (each already newline-free) into a listing parser the way a file read would. */
|
|
|
|
|
+ Usm.DcbaListing dcba_listing_from_lines(string[] lines) throws Error {
|
|
|
|
|
+ var builder = new StringBuilder();
|
|
|
|
|
+ foreach(var line in lines) {
|
|
|
|
|
+ builder.append_printf("%s\n", line);
|
|
|
|
|
+ }
|
|
|
|
|
+ var stream = new DataInputStream(new MemoryInputStream.from_data(builder.str.data));
|
|
|
|
|
+ return new Usm.DcbaListing.from_stream(stream);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /**
|
|
|
|
|
+ * Builds a signed ARTIFACTS.usml body for {@link entry_lines} with the
|
|
|
|
|
+ * protocol the repository tooling uses: SHA-512 over the bare
|
|
|
|
|
+ * serialised entry lines (no newline), ed25519-signed.
|
|
|
|
|
+ */
|
|
|
|
|
+ string dcba_signed_listing(string[] entry_lines, uint8[] public_key, uint8[] private_key) throws Error {
|
|
|
|
|
+ var checksum = new Checksum(ChecksumType.SHA512);
|
|
|
|
|
+ foreach(var line in entry_lines) {
|
|
|
|
|
+ checksum.update(line.data, line.data.length);
|
|
|
|
|
+ }
|
|
|
|
|
+ var buffer = new uint8[ChecksumType.SHA512.get_length()];
|
|
|
|
|
+ size_t length = buffer.length;
|
|
|
|
|
+ checksum.get_digest(buffer, ref length);
|
|
|
|
|
+ var signed = Sodium.Asymmetric.Signing.sign(buffer, private_key);
|
|
|
|
|
+
|
|
|
|
|
+ var signatures = new StringBuilder();
|
|
|
|
|
+ signatures.append("{\"type\":\"signatures\",\"signatures\":[");
|
|
|
|
|
+ signatures.append_printf("{\"key\":\"%s\",\"signature\":\"%s\"}",
|
|
|
|
|
+ Base64.encode(public_key), Base64.encode(signed));
|
|
|
|
|
+ signatures.append("]}");
|
|
|
|
|
+
|
|
|
|
|
+ var listing = new StringBuilder();
|
|
|
|
|
+ foreach(var line in entry_lines) {
|
|
|
|
|
+ listing.append_printf("%s\n", line);
|
|
|
|
|
+ }
|
|
|
|
|
+ listing.append(signatures.str);
|
|
|
|
|
+ return listing.str;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ void test_dcba_listing_parses_entries_and_signatures() throws Error {
|
|
|
|
|
+ var public_key = new uint8[Sodium.Asymmetric.Signing.PUBLIC_KEY_BYTES];
|
|
|
|
|
+ var private_key = new uint8[Sodium.Asymmetric.Signing.SECRET_KEY_BYTES];
|
|
|
|
|
+ Sodium.Asymmetric.Signing.generate_keypair(public_key, private_key);
|
|
|
|
|
+
|
|
|
|
|
+ var entries = new string[] {
|
|
|
|
|
+ "{\"type\":\"dcba\",\"name\":\"app\",\"version\":\"1.0.0\",\"path\":\"app-1.0.0.dcba\",\"sha512\":\"AQID\",\"size\":128}",
|
|
|
|
|
+ "{\"type\":\"dcba\",\"name\":\"app\",\"version\":\"1.2\",\"path\":\"app-1.2.dcba\",\"sha512\":\"BQE=\"}"
|
|
|
|
|
+ };
|
|
|
|
|
+ var listing = dcba_listing_from_lines(
|
|
|
|
|
+ new string[] { entries[0], entries[1], "{\"type\":\"signatures\",\"signatures\":[]}" });
|
|
|
|
|
+
|
|
|
|
|
+ check(listing.entries.count() == 2, "a DCBA listing parses every dcba entry line");
|
|
|
|
|
+ var first = listing.entries.first_or_default(e => true);
|
|
|
|
|
+ check(first != null && ((!)first).name == "app" && ((!)first).version == "1.0.0"
|
|
|
|
|
+ && ((!)first).path == "app-1.0.0.dcba" && ((!)first).size == 128, "entry fields round-trip verbatim");
|
|
|
|
|
+ var second = listing.entries.first_or_default(e => e.version == "1.2");
|
|
|
|
|
+ check(second != null && ((!)second).size == 0, "an entry without size materialises as 0");
|
|
|
|
|
+ check(first != null && ((!)first).sha512sum.to_array().length == 3, "the base64 sha512 decodes to its raw bytes");
|
|
|
|
|
+
|
|
|
|
|
+ // Sign/verify round trip with the protocol the repository tooling uses
|
|
|
|
|
+ var signed = dcba_signed_listing(entries, public_key, private_key);
|
|
|
|
|
+ var verified = new Usm.DcbaListing.from_stream(new DataInputStream(new MemoryInputStream.from_data(signed.data)));
|
|
|
|
|
+ check(verified.valid_signature(Wrap.byte_array(public_key)), "a signed DCBA listing verifies against its key");
|
|
|
|
|
+
|
|
|
|
|
+ // Tamper detection: one flipped byte in an entry invalidates the signature
|
|
|
|
|
+ var tampered = signed.replace("app-1.2.dcba", "app-1.3.dcba");
|
|
|
|
|
+ var broken = new Usm.DcbaListing.from_stream(new DataInputStream(new MemoryInputStream.from_data(tampered.data)));
|
|
|
|
|
+ check(!broken.valid_signature(Wrap.byte_array(public_key)), "a flipped byte in an entry fails signature verification");
|
|
|
|
|
+
|
|
|
|
|
+ // A wrong key never verifies, even untouched
|
|
|
|
|
+ var other_public = new uint8[Sodium.Asymmetric.Signing.PUBLIC_KEY_BYTES];
|
|
|
|
|
+ var other_private = new uint8[Sodium.Asymmetric.Signing.SECRET_KEY_BYTES];
|
|
|
|
|
+ Sodium.Asymmetric.Signing.generate_keypair(other_public, other_private);
|
|
|
|
|
+ check(!verified.valid_signature(Wrap.byte_array(other_public)), "an unrelated key fails signature verification");
|
|
|
|
|
+
|
|
|
|
|
+ // A listing without a signatures line is rejected outright
|
|
|
|
|
+ var threw = false;
|
|
|
|
|
+ try {
|
|
|
|
|
+ dcba_listing_from_lines(new string[] { entries[0] });
|
|
|
|
|
+ }
|
|
|
|
|
+ catch(Error e) {
|
|
|
|
|
+ threw = true;
|
|
|
|
|
+ }
|
|
|
|
|
+ check(threw, "a DCBA listing ending without a signatures line is an error");
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ void test_dcba_listing_name_version_match() throws Error {
|
|
|
|
|
+ var listing = dcba_listing_from_lines(new string[] {
|
|
|
|
|
+ "{\"type\":\"dcba\",\"name\":\"app\",\"version\":\"1.0.0\",\"path\":\"app-1.0.0.dcba\",\"sha512\":\"AQID\"}",
|
|
|
|
|
+ "{\"type\":\"dcba\",\"name\":\"app\",\"version\":\"1.0\",\"path\":\"app-1.0.dcba\",\"sha512\":\"BQE=\"}",
|
|
|
|
|
+ "{\"type\":\"dcba\",\"name\":\"lib\",\"version\":\"1.0.0\",\"path\":\"lib-1.0.0.dcba\",\"sha512\":\"Bwg=\"}",
|
|
|
|
|
+ "{\"type\":\"signatures\",\"signatures\":[]}"
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ var exact = listing.find("app", "1.0.0");
|
|
|
|
|
+ check(exact != null && ((!)exact).path == "app-1.0.0.dcba", "name+version find the exact entry");
|
|
|
|
|
+ var short_version = listing.find("app", "1.0");
|
|
|
|
|
+ check(short_version != null && ((!)short_version).path == "app-1.0.dcba", "the version string matches verbatim (1.0 is not 1.0.0)");
|
|
|
|
|
+ check(listing.find("app", "2.0.0") == null, "an absent version finds nothing");
|
|
|
|
|
+ check(listing.find("other", "1.0.0") == null, "an absent name finds nothing");
|
|
|
|
|
+ check(listing.find("lib", "1.0.0") != null, "a different package's entry is found by its own name");
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /**
|
|
|
|
|
+ * A minimal DCBA repository on disk — descriptor, public/ with
|
|
|
|
|
+ * ARTIFACTS.usml and one artifact — served through a `file://` URL so
|
|
|
|
|
+ * the GIO client exercises the whole download path without a network.
|
|
|
|
|
+ */
|
|
|
|
|
+ string dcba_make_repository(string scratch, string name, uint8[] public_key, uint8[] private_key,
|
|
|
|
|
+ string artifact_name, string artifact_version, uint8[] artifact_bytes, bool checksum_matches) throws Error {
|
|
|
|
|
+ var root = Path.build_filename(scratch, @"repo-$name");
|
|
|
|
|
+ var public_dir = Path.build_filename(root, "public");
|
|
|
|
|
+ DirUtils.create_with_parents(public_dir, 0755);
|
|
|
|
|
+
|
|
|
|
|
+ var artifact_path = Path.build_filename(public_dir, @"$artifact_name-$artifact_version.dcba");
|
|
|
|
|
+ FileUtils.set_data(artifact_path, artifact_bytes);
|
|
|
|
|
+
|
|
|
|
|
+ var checksum = new Checksum(ChecksumType.SHA512);
|
|
|
|
|
+ checksum.update(artifact_bytes, artifact_bytes.length);
|
|
|
|
|
+ var digest = new uint8[ChecksumType.SHA512.get_length()];
|
|
|
|
|
+ size_t length = digest.length;
|
|
|
|
|
+ checksum.get_digest(digest, ref length);
|
|
|
|
|
+ if(!checksum_matches) {
|
|
|
|
|
+ digest[0] = digest[0] ^ 0xFF;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ var entry_line = @"{\"type\":\"dcba\",\"name\":\"$artifact_name\",\"version\":\"$artifact_version\",\"path\":\"$(Path.get_basename(artifact_path))\",\"sha512\":\"$(Base64.encode(digest))\",\"size\":$(artifact_bytes.length)}";
|
|
|
|
|
+ var listing = dcba_signed_listing(new string[] { entry_line }, public_key, private_key);
|
|
|
|
|
+ FileUtils.set_contents(Path.build_filename(public_dir, "ARTIFACTS.usml"), listing);
|
|
|
|
|
+
|
|
|
|
|
+ var descriptor = @"{
|
|
|
|
|
+ \"name\": \"$name\",
|
|
|
|
|
+ \"summary\": \"dcba fixture\",
|
|
|
|
|
+ \"url\": \"file://$public_dir\",
|
|
|
|
|
+ \"key\": \"$(Base64.encode(public_key))\",
|
|
|
|
|
+ \"cache_expiration\": 60
|
|
|
|
|
+}";
|
|
|
|
|
+ FileUtils.set_contents(Path.build_filename(root, @"$name.dcbar"), descriptor);
|
|
|
|
|
+ return descriptor;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ void test_dcba_repository_download_and_verify() throws Error {
|
|
|
|
|
+ var scratch = make_scratch();
|
|
|
|
|
+ var public_key = new uint8[Sodium.Asymmetric.Signing.PUBLIC_KEY_BYTES];
|
|
|
|
|
+ var private_key = new uint8[Sodium.Asymmetric.Signing.SECRET_KEY_BYTES];
|
|
|
|
|
+ Sodium.Asymmetric.Signing.generate_keypair(public_key, private_key);
|
|
|
|
|
+
|
|
|
|
|
+ var artifact = new uint8[] { 1, 2, 3, 4, 5, 6, 7, 8, 9 };
|
|
|
|
|
+ var descriptor = dcba_make_repository(scratch, "dcba-good", public_key, private_key, "app", "1.0.0", artifact, true);
|
|
|
|
|
+
|
|
|
|
|
+ var state = make_state(scratch);
|
|
|
|
|
+ DirUtils.create_with_parents(Path.build_filename(scratch, "config", "repos.d"), 0755);
|
|
|
|
|
+ FileUtils.set_contents(Path.build_filename(scratch, "config", "repos.d", "dcba-good.dcbar"), descriptor);
|
|
|
|
|
+
|
|
|
|
|
+ var repos = state.get_dcba_repositories().to_vector();
|
|
|
|
|
+ check(repos.length == 1 && repos[0].name == "dcba-good", "a .dcbar descriptor in repos.d parses as a DCBA repository");
|
|
|
|
|
+
|
|
|
|
|
+ state.refresh_dcba_list(repos[0]);
|
|
|
|
|
+ var listing = state.get_latest_dcba_list(repos[0]);
|
|
|
|
|
+ check(listing != null, "the refreshed ARTIFACTS.usml is cached and readable");
|
|
|
|
|
+ check(listing != null && ((!)listing).valid_signature(repos[0].key), "the cached artifact listing verifies against the repository key");
|
|
|
|
|
+ var entry = listing != null ? ((!)listing).find("app", "1.0.0") : null;
|
|
|
|
|
+ check(entry != null && ((!)entry).size == artifact.length, "the artifact entry carries its size");
|
|
|
|
|
+
|
|
|
|
|
+ // The by-path download lands the artifact bytes through the GIO client
|
|
|
|
|
+ var client = repos[0].get_client();
|
|
|
|
|
+ var downloaded = Path.build_filename(scratch, "downloaded.dcba");
|
|
|
|
|
+ string? reported = null;
|
|
|
|
|
+ client.download_file(((!)entry).path, downloaded, (f, c, t) => { reported = f; });
|
|
|
|
|
+ uint8[] read_back = {};
|
|
|
|
|
+ check(FileUtils.get_data(downloaded, out read_back) && read_back.length == artifact.length, "download_file fetches the artifact over file://");
|
|
|
|
|
+ check(reported != null && ((!)reported).has_suffix(".dcba"), "the progress callback reports the artifact filename");
|
|
|
|
|
+
|
|
|
|
|
+ // Checksum verification: happy path and mismatch
|
|
|
|
|
+ var checksum = new Checksum(ChecksumType.SHA512);
|
|
|
|
|
+ checksum.update(read_back, read_back.length);
|
|
|
|
|
+ var digest = new uint8[ChecksumType.SHA512.get_length()];
|
|
|
|
|
+ size_t length = digest.length;
|
|
|
|
|
+ checksum.get_digest(digest, ref length);
|
|
|
|
|
+ check(((!)entry).sha512sum.equals(Wrap.array(digest)), "the downloaded artifact hashes to the listing sha512");
|
|
|
|
|
+
|
|
|
|
|
+ var tampered_entry = new Usm.DcbaListingEntry() {
|
|
|
|
|
+ name = "app", version = "1.0.0", path = ((!)entry).path,
|
|
|
|
|
+ sha512sum = Wrap.byte_array(new uint8[] { 1, 2, 3 })
|
|
|
|
|
+ };
|
|
|
|
|
+ check(!tampered_entry.sha512sum.equals(Wrap.array(digest)), "a mismatching sha512 fails the binary digest comparison");
|
|
|
|
|
+
|
|
|
|
|
+ Usm.Util.delete_tree(scratch);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ void test_dcba_repositories_filtered_from_source_repositories() throws Error {
|
|
|
|
|
+ var scratch = make_scratch();
|
|
|
|
|
+ var state = make_state(scratch);
|
|
|
|
|
+
|
|
|
|
|
+ var repos_dir = Path.build_filename(scratch, "config", "repos.d");
|
|
|
|
|
+ DirUtils.create_with_parents(repos_dir, 0755);
|
|
|
|
|
+ FileUtils.set_contents(Path.build_filename(repos_dir, "source.usmr"),
|
|
|
|
|
+ "{\"name\": \"source\", \"summary\": \"src\", \"url\": \"file:///tmp\", \"key\": \"AQ==\", \"cache_expiration\": 60}");
|
|
|
|
|
+ FileUtils.set_contents(Path.build_filename(repos_dir, "binaries.dcbar"),
|
|
|
|
|
+ "{\"name\": \"binaries\", \"summary\": \"bin\", \"url\": \"file:///tmp\", \"key\": \"AQ==\", \"cache_expiration\": 60}");
|
|
|
|
|
+ FileUtils.set_contents(Path.build_filename(repos_dir, "notes.txt"), "not a repository");
|
|
|
|
|
+
|
|
|
|
|
+ var sources = state.get_repositories().to_vector();
|
|
|
|
|
+ check(sources.length == 1 && sources[0].name == "source", "get_repositories returns only .usmr descriptors");
|
|
|
|
|
+ var binaries = state.get_dcba_repositories().to_vector();
|
|
|
|
|
+ check(binaries.length == 1 && binaries[0].name == "binaries", "get_dcba_repositories returns only .dcbar descriptors");
|
|
|
|
|
+ check(binaries[0].key.to_array().length == 1, "the base64 key of a .dcbar descriptor decodes");
|
|
|
|
|
+
|
|
|
|
|
+ Usm.Util.delete_tree(scratch);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ void test_dcba_origin_information_field() throws Error {
|
|
|
|
|
+ var scratch = make_scratch();
|
|
|
|
|
+ var cache_path = Path.build_filename(scratch, "dcba-pkg-1.0.0");
|
|
|
|
|
+ DirUtils.create(cache_path, 0755);
|
|
|
|
|
+ var cached = new Usm.CachedPackage(cache_path);
|
|
|
|
|
+
|
|
|
|
|
+ var record = new Usm.OriginInformation() {
|
|
|
|
|
+ repository = "source",
|
|
|
|
|
+ signature_verified = true,
|
|
|
|
|
+ dcba = "binaries"
|
|
|
|
|
+ };
|
|
|
|
|
+ cached.update_origin_information(record);
|
|
|
|
|
+ var read = cached.get_origin_information();
|
|
|
|
|
+ check(read.dcba == "binaries", "the dcba provenance field survives an origin-info round trip");
|
|
|
|
|
+ check(read.repository == "source" && read.signature_verified, "the other provenance fields survive alongside dcba");
|
|
|
|
|
+
|
|
|
|
|
+ // Read-modify-write keeps the field when a later record updates another field
|
|
|
|
|
+ read.explicitly_installed = true;
|
|
|
|
|
+ cached.update_origin_information(read);
|
|
|
|
|
+ check(cached.get_origin_information().dcba == "binaries" && cached.get_origin_information().explicitly_installed,
|
|
|
|
|
+ "a read-modify-written record keeps its dcba provenance");
|
|
|
|
|
+
|
|
|
|
|
+ FileUtils.set_contents(Path.build_filename(cache_path, "origin-info"),
|
|
|
|
|
+ "{\"repository\": \"source\", \"listfile\": null, \"original_path\": \"p.usmc\", \"signature_verified\": true}");
|
|
|
|
|
+ check(cached.get_origin_information().dcba == null, "a pre-dcba origin-info file parses back with a null dcba");
|
|
|
|
|
+
|
|
|
|
|
+ var legacy = new Usm.CachedPackage(cache_path);
|
|
|
|
|
+ legacy.update_origin_information(new Usm.OriginInformation());
|
|
|
|
|
+ check(cached.get_origin_information().dcba == null && !cached.get_origin_information().explicitly_installed,
|
|
|
|
|
+ "a fresh record serialises without inventing dcba provenance");
|
|
|
|
|
+
|
|
|
|
|
+ Usm.Util.delete_tree(scratch);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
// ---- Dependency-phase model -------------------------------------------------
|
|
// ---- Dependency-phase model -------------------------------------------------
|
|
|
|
|
|
|
|
/** Minimal manifest with the given "depends" section verbatim. */
|
|
/** Minimal manifest with the given "depends" section verbatim. */
|
|
@@ -2316,6 +2563,18 @@ echo "Installing libmeson-pkg.so to /usr/lib"
|
|
|
print("FAIL transaction rollback test threw: %s\n", e.message);
|
|
print("FAIL transaction rollback test threw: %s\n", e.message);
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+
|
|
|
|
|
+ try {
|
|
|
|
|
+ test_dcba_listing_parses_entries_and_signatures();
|
|
|
|
|
+ test_dcba_listing_name_version_match();
|
|
|
|
|
+ test_dcba_repository_download_and_verify();
|
|
|
|
|
+ test_dcba_repositories_filtered_from_source_repositories();
|
|
|
|
|
+ test_dcba_origin_information_field();
|
|
|
|
|
+ }
|
|
|
|
|
+ catch(Error e) {
|
|
|
|
|
+ failures++;
|
|
|
|
|
+ print("FAIL dcba test threw: %s\n", e.message);
|
|
|
|
|
+ }
|
|
|
try {
|
|
try {
|
|
|
test_unarchive_with_progress();
|
|
test_unarchive_with_progress();
|
|
|
test_resolver_resolution_progress();
|
|
test_resolver_resolution_progress();
|