|
|
@@ -256,6 +256,253 @@ namespace Usm.Tests {
|
|
|
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 -------------------------------------------------
|
|
|
|
|
|
/** Minimal manifest with the given "depends" section verbatim. */
|
|
|
@@ -1761,6 +2008,234 @@ exit 1
|
|
|
Usm.Util.delete_tree(scratch);
|
|
|
}
|
|
|
|
|
|
+ // ---- Conditional build-artifact re-archiving --------------------------------
|
|
|
+
|
|
|
+ /**
|
|
|
+ * A CachedPackage over a scratch cache directory holding a build
|
|
|
+ * directory with a fixed tree (a file, a sub-directory with a file
|
|
|
+ * and a symbolic link) — the seed for the re-archive tests.
|
|
|
+ */
|
|
|
+ Usm.CachedPackage make_built_package(string scratch, string name) throws Error {
|
|
|
+ var cache_path = Path.build_filename(scratch, "state", "packages", @"$name-1.0.0");
|
|
|
+ DirUtils.create_with_parents(Path.build_filename(cache_path, "build", "sub"), 0755);
|
|
|
+ FileUtils.set_contents(Path.build_filename(cache_path, "build", "one.txt"), "first\n");
|
|
|
+ FileUtils.set_contents(Path.build_filename(cache_path, "build", "sub", "two.txt"), "second\n");
|
|
|
+ File.new_for_path(Path.build_filename(cache_path, "build", "link"))
|
|
|
+ .make_symbolic_link("one.txt");
|
|
|
+ return new Usm.CachedPackage(cache_path);
|
|
|
+ }
|
|
|
+
|
|
|
+ /** Archives the package's build directory and records its digest sidecar. */
|
|
|
+ void archive_built_package(Usm.CachedPackage package) throws Error {
|
|
|
+ var build_dir = Path.build_filename(package.state_path, "build");
|
|
|
+ var archive_path = Path.build_filename(package.state_path, "build.tar.xz");
|
|
|
+ Usm.Util.archive(build_dir, archive_path);
|
|
|
+ FileUtils.set_contents(Path.build_filename(package.state_path, "build.tar.xz.digest"),
|
|
|
+ Usm.Util.tree_digest(build_dir) + "\n");
|
|
|
+ }
|
|
|
+
|
|
|
+ /** Extracts `archive` into a fresh scratch directory and returns the contents of `member`. */
|
|
|
+ string archived_member(string scratch, string archive, string member) throws Error {
|
|
|
+ var extracted = Path.build_filename(scratch, @"extract-$(Uuid.string_random())");
|
|
|
+ DirUtils.create(extracted, 0755);
|
|
|
+ Usm.Util.unarchive(archive, extracted);
|
|
|
+ string contents = null;
|
|
|
+ FileUtils.get_contents(Path.build_filename(extracted, member), out contents);
|
|
|
+ return contents;
|
|
|
+ }
|
|
|
+
|
|
|
+ /** Modification time of `path` in seconds since the epoch. */
|
|
|
+ int64 file_mtime(string path) {
|
|
|
+ return Stat(path).st_mtime;
|
|
|
+ }
|
|
|
+
|
|
|
+ void test_archive_build_skips_unchanged_tree() throws Error {
|
|
|
+ var scratch = make_scratch();
|
|
|
+ var package = make_built_package(scratch, "skip-pkg");
|
|
|
+ archive_built_package(package);
|
|
|
+
|
|
|
+ var archive_path = Path.build_filename(package.state_path, "build.tar.xz");
|
|
|
+ var digest_path = Path.build_filename(package.state_path, "build.tar.xz.digest");
|
|
|
+ string before = null;
|
|
|
+ FileUtils.get_contents(archive_path, out before);
|
|
|
+ var before_mtime = file_mtime(archive_path);
|
|
|
+
|
|
|
+ var last_fraction = -1.0f;
|
|
|
+ package.archive_build((fraction) => { last_fraction = fraction; });
|
|
|
+
|
|
|
+ string after = null;
|
|
|
+ FileUtils.get_contents(archive_path, out after);
|
|
|
+ check(after == before, "an unchanged build tree keeps the archive bytes");
|
|
|
+ check(file_mtime(archive_path) == before_mtime, "an unchanged build tree keeps the archive mtime");
|
|
|
+ check(!File.new_for_path(Path.build_filename(package.state_path, "build")).query_exists(),
|
|
|
+ "skipping the re-archive still cleans the build directory");
|
|
|
+ check(File.new_for_path(digest_path).query_exists(), "skipping the re-archive keeps the digest sidecar");
|
|
|
+ check(last_fraction == 1.0f, "skipping the re-archive still completes progress");
|
|
|
+
|
|
|
+ Usm.Util.delete_tree(scratch);
|
|
|
+ }
|
|
|
+
|
|
|
+ /** One mutation of a build tree before it is re-archived. */
|
|
|
+ delegate void BuildTreeMutation(string build_dir) throws Error;
|
|
|
+
|
|
|
+ /**
|
|
|
+ * One re-archive-on-change case: seeds a package named <name> with a
|
|
|
+ * build tree, archives it, applies <mutate> (which may throw — a
|
|
|
+ * failed mutation must fail the test, not silently re-test the
|
|
|
+ * unchanged tree), re-archives and asserts the archive was rewritten.
|
|
|
+ */
|
|
|
+ void rearchived_after_change(string scratch, string name, owned BuildTreeMutation mutate) throws Error {
|
|
|
+ var package = make_built_package(scratch, name);
|
|
|
+ archive_built_package(package);
|
|
|
+ var digest_path = Path.build_filename(package.state_path, "build.tar.xz.digest");
|
|
|
+ string digest_before = null;
|
|
|
+ FileUtils.get_contents(digest_path, out digest_before);
|
|
|
+
|
|
|
+ mutate(Path.build_filename(package.state_path, "build"));
|
|
|
+ // Computed before archive_build cleans the directory: the digest
|
|
|
+ // the fresh sidecar has to describe
|
|
|
+ var changed_tree_digest = Usm.Util.tree_digest(Path.build_filename(package.state_path, "build"));
|
|
|
+ package.archive_build(null);
|
|
|
+
|
|
|
+ string digest_after = null;
|
|
|
+ FileUtils.get_contents(digest_path, out digest_after);
|
|
|
+ check(digest_after.strip() != digest_before.strip(),
|
|
|
+ @"a changed build tree ($name) re-archives into a new digest sidecar");
|
|
|
+ check(digest_after.strip() == changed_tree_digest,
|
|
|
+ @"a changed build tree ($name) records its own tree in the fresh sidecar");
|
|
|
+ check(!File.new_for_path(Path.build_filename(package.state_path, "build")).query_exists(),
|
|
|
+ @"a changed build tree ($name) still cleans the build directory");
|
|
|
+
|
|
|
+ Usm.Util.delete_tree(scratch);
|
|
|
+ }
|
|
|
+
|
|
|
+ void test_archive_build_rearchives_changes() throws Error {
|
|
|
+ var scratch = make_scratch();
|
|
|
+
|
|
|
+ rearchived_after_change(scratch, "mod-pkg", (build) => {
|
|
|
+ FileUtils.set_contents(Path.build_filename(build, "one.txt"), "changed\n");
|
|
|
+ });
|
|
|
+ check(true, "a modified file re-archives");
|
|
|
+
|
|
|
+ rearchived_after_change(scratch, "add-pkg", (build) => {
|
|
|
+ FileUtils.set_contents(Path.build_filename(build, "new.txt"), "new\n");
|
|
|
+ });
|
|
|
+ check(true, "an added file re-archives");
|
|
|
+
|
|
|
+ rearchived_after_change(scratch, "rm-pkg", (build) => {
|
|
|
+ File.new_for_path(Path.build_filename(build, "sub", "two.txt")).delete();
|
|
|
+ });
|
|
|
+ check(true, "a removed file re-archives");
|
|
|
+
|
|
|
+ rearchived_after_change(scratch, "mode-pkg", (build) => {
|
|
|
+ FileUtils.chmod(Path.build_filename(build, "one.txt"), 0700);
|
|
|
+ });
|
|
|
+ check(true, "a changed file mode re-archives");
|
|
|
+
|
|
|
+ rearchived_after_change(scratch, "link-pkg", (build) => {
|
|
|
+ File.new_for_path(Path.build_filename(build, "link")).delete();
|
|
|
+ File.new_for_path(Path.build_filename(build, "link")).make_symbolic_link("sub/two.txt");
|
|
|
+ });
|
|
|
+ check(true, "a changed symlink target re-archives");
|
|
|
+ }
|
|
|
+
|
|
|
+ void test_archive_build_rearchives_modified_content() throws Error {
|
|
|
+ var scratch = make_scratch();
|
|
|
+ var package = make_built_package(scratch, "content-pkg");
|
|
|
+ archive_built_package(package);
|
|
|
+
|
|
|
+ var archive_path = Path.build_filename(package.state_path, "build.tar.xz");
|
|
|
+ FileUtils.set_contents(Path.build_filename(package.state_path, "build", "one.txt"), "changed\n");
|
|
|
+ package.archive_build(null);
|
|
|
+
|
|
|
+ check(archived_member(scratch, archive_path, "one.txt") == "changed\n",
|
|
|
+ "a modified file's new content lands in the re-archived archive");
|
|
|
+
|
|
|
+ Usm.Util.delete_tree(scratch);
|
|
|
+ }
|
|
|
+
|
|
|
+ void test_archive_build_rearchives_without_sidecar() throws Error {
|
|
|
+ var scratch = make_scratch();
|
|
|
+ var package = make_built_package(scratch, "legacy-pkg");
|
|
|
+ var build_dir = Path.build_filename(package.state_path, "build");
|
|
|
+ var archive_path = Path.build_filename(package.state_path, "build.tar.xz");
|
|
|
+ Usm.Util.archive(build_dir, archive_path); // legacy: no digest sidecar
|
|
|
+ var tree_digest_before = Usm.Util.tree_digest(build_dir);
|
|
|
+
|
|
|
+ package.archive_build(null);
|
|
|
+
|
|
|
+ string digest = null;
|
|
|
+ FileUtils.get_contents(Path.build_filename(package.state_path, "build.tar.xz.digest"), out digest);
|
|
|
+ check(digest != null && digest.strip() == tree_digest_before,
|
|
|
+ "an archive without a digest sidecar re-archives and records the digest sidecar");
|
|
|
+ check(archived_member(scratch, archive_path, "one.txt") == "first\n",
|
|
|
+ "the re-archived legacy archive holds the build tree's contents");
|
|
|
+
|
|
|
+ Usm.Util.delete_tree(scratch);
|
|
|
+ }
|
|
|
+
|
|
|
+ void test_build_digest_sidecar_lifecycle() throws Error {
|
|
|
+ var scratch = make_scratch();
|
|
|
+ var package = make_built_package(scratch, "cycle-pkg");
|
|
|
+ var cache_path = package.state_path;
|
|
|
+ var archive_path = Path.build_filename(cache_path, "build.tar.xz");
|
|
|
+ var digest_path = Path.build_filename(cache_path, "build.tar.xz.digest");
|
|
|
+
|
|
|
+ // get_build_archive archives a bare build directory
|
|
|
+ var returned = package.get_build_archive();
|
|
|
+ check(returned == archive_path, "get_build_archive creates the archive from the build directory");
|
|
|
+ check(File.new_for_path(digest_path).query_exists(),
|
|
|
+ "get_build_archive records the digest sidecar alongside the archive");
|
|
|
+
|
|
|
+ // An archive-only cache keeps its sidecar; unarchiving leaves it alone
|
|
|
+ FileUtils.set_contents(digest_path, "describes-the-archive-not-the-tree\n");
|
|
|
+ package.unarchive_build();
|
|
|
+ check(File.new_for_path(digest_path).query_exists(),
|
|
|
+ "unarchiving a build artifact leaves the digest sidecar alone");
|
|
|
+
|
|
|
+ // A second archive_build over the restored tree matches the digest
|
|
|
+ var digest = Usm.Util.tree_digest(Path.build_filename(cache_path, "build"));
|
|
|
+ FileUtils.set_contents(digest_path, digest + "\n");
|
|
|
+ var before_mtime = file_mtime(archive_path);
|
|
|
+ package.archive_build(null);
|
|
|
+ check(file_mtime(archive_path) == before_mtime,
|
|
|
+ "an archive restored and re-archived unchanged keeps its bytes");
|
|
|
+
|
|
|
+ // clean_build_cache removes the archive together with the sidecar
|
|
|
+ package.clean_build_cache();
|
|
|
+ check(!File.new_for_path(archive_path).query_exists() && !File.new_for_path(digest_path).query_exists(),
|
|
|
+ "clean_build_cache removes the archive and the digest sidecar together");
|
|
|
+
|
|
|
+ Usm.Util.delete_tree(scratch);
|
|
|
+ }
|
|
|
+
|
|
|
+ void test_tree_digest_determinism() throws Error {
|
|
|
+ var scratch = make_scratch();
|
|
|
+
|
|
|
+ var tree = Path.build_filename(scratch, "tree");
|
|
|
+ DirUtils.create_with_parents(Path.build_filename(tree, "d"), 0755);
|
|
|
+ FileUtils.set_contents(Path.build_filename(tree, "a.txt"), "alpha\n");
|
|
|
+ FileUtils.set_contents(Path.build_filename(tree, "d", "b.txt"), "beta\n");
|
|
|
+ File.new_for_path(Path.build_filename(tree, "d", "link")).make_symbolic_link("../a.txt");
|
|
|
+
|
|
|
+ var first = Usm.Util.tree_digest(tree);
|
|
|
+ check(first == Usm.Util.tree_digest(tree), "hashing the same tree twice yields the same digest");
|
|
|
+
|
|
|
+ // Same content at a different path is a different tree
|
|
|
+ var moved = Path.build_filename(scratch, "moved");
|
|
|
+ DirUtils.create_with_parents(Path.build_filename(moved, "d"), 0755);
|
|
|
+ FileUtils.set_contents(Path.build_filename(moved, "z.txt"), "alpha\n");
|
|
|
+ FileUtils.set_contents(Path.build_filename(moved, "d", "b.txt"), "beta\n");
|
|
|
+ File.new_for_path(Path.build_filename(moved, "d", "link")).make_symbolic_link("../a.txt");
|
|
|
+ check(Usm.Util.tree_digest(moved) != first, "a same-content file at a different path digests differently");
|
|
|
+
|
|
|
+ // A dot-prefixed temp file inside the tree is part of the tree
|
|
|
+ FileUtils.set_contents(Path.build_filename(tree, ".usm-tmp"), "scratch\n");
|
|
|
+ check(Usm.Util.tree_digest(tree) != first, "a temporary file inside the tree changes the digest");
|
|
|
+
|
|
|
+ Usm.Util.delete_tree(scratch);
|
|
|
+ }
|
|
|
+
|
|
|
void test_build_failure_without_cache_propagates() throws Error {
|
|
|
var original_dir = Environment.get_current_dir();
|
|
|
var scratch = make_scratch();
|
|
|
@@ -2035,6 +2510,106 @@ exit 0
|
|
|
Usm.Util.delete_tree(scratch);
|
|
|
}
|
|
|
|
|
|
+ // ---- DCBA repository CLI helpers --------------------------------------------------
|
|
|
+
|
|
|
+ void test_dcba_artifact_filename_parsing() throws Error {
|
|
|
+ var plain = Usm.DcbaArtifact.parse_filename("app-1.0.0.dcba");
|
|
|
+ check(plain != null && ((!)plain).name == "app" && ((!)plain).version == "1.0.0", "a plain name-version filename splits cleanly");
|
|
|
+
|
|
|
+ var released = Usm.DcbaArtifact.parse_filename("app-1.0.0+2.dcba");
|
|
|
+ check(released != null && ((!)released).name == "app" && ((!)released).version == "1.0.0+2", "a +release suffix stays inside the version");
|
|
|
+
|
|
|
+ var dashed = Usm.DcbaArtifact.parse_filename("lib-base-1.2+3.dcba");
|
|
|
+ check(dashed != null && ((!)dashed).name == "lib-base" && ((!)dashed).version == "1.2+3", "a dashed package name keeps its leading components");
|
|
|
+
|
|
|
+ var short_version = Usm.DcbaArtifact.parse_filename("app-1.dcba");
|
|
|
+ check(short_version != null && ((!)short_version).name == "app" && ((!)short_version).version == "1", "a single-component version parses");
|
|
|
+
|
|
|
+ var arch_tail = Usm.DcbaArtifact.parse_filename("app-1.0.0-x86_64.dcba");
|
|
|
+ check(arch_tail != null && ((!)arch_tail).name == "app", "an unparseable tail falls back to an earlier dash");
|
|
|
+ check(arch_tail != null && ((!)arch_tail).version == "1.0.0", "the unparseable tail is excluded and 1.0.0 is the version");
|
|
|
+
|
|
|
+ check(Usm.DcbaArtifact.parse_filename("no-version.dcba") == null, "a filename without any version is rejected");
|
|
|
+ check(Usm.DcbaArtifact.parse_filename("-1.0.0.dcba") == null, "an empty name is rejected");
|
|
|
+ check(Usm.DcbaArtifact.parse_filename("app.txt") == null, "a non-dcba extension is rejected");
|
|
|
+ check(Usm.DcbaArtifact.parse_filename(".dcba") == null, "an empty stem is rejected");
|
|
|
+
|
|
|
+ var a = new Usm.DcbaArtifact("app", "1.0.0");
|
|
|
+ var b = new Usm.DcbaArtifact("app", "1.0.0");
|
|
|
+ var c = new Usm.DcbaArtifact("app", "2.0");
|
|
|
+ check(a.same_artifact(b), "identical name+version is the same artifact");
|
|
|
+ check(!a.same_artifact(c), "a different version is a different artifact, not a conflict");
|
|
|
+ }
|
|
|
+
|
|
|
+ void test_dcba_listing_writer_round_trip() 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 entries = new Vector<Usm.DcbaListingEntry>();
|
|
|
+ var first_bytes = new uint8[] { 1, 2, 3, 4 };
|
|
|
+ entries.add(new Usm.DcbaListingEntry() {
|
|
|
+ name = "app", version = "1.0.0+2", path = "app-1.0.0+2.dcba",
|
|
|
+ sha512sum = Wrap.byte_array(first_bytes), size = first_bytes.length
|
|
|
+ });
|
|
|
+ var second_bytes = new uint8[] { 9 };
|
|
|
+ entries.add(new Usm.DcbaListingEntry() {
|
|
|
+ name = "lib-base", version = "1.2", path = "lib-base-1.2.dcba",
|
|
|
+ sha512sum = Wrap.byte_array(second_bytes)
|
|
|
+ });
|
|
|
+
|
|
|
+ var listing_path = Path.build_filename(scratch, "ARTIFACTS.usml");
|
|
|
+ Usm.DcbaListingWriter.write(listing_path, entries, public_key, private_key);
|
|
|
+
|
|
|
+ // The written listing parses back with matching entries and a valid signature
|
|
|
+ var listing = new Usm.DcbaListing.from_stream(new DataInputStream(File.new_for_path(listing_path).read()));
|
|
|
+ var parsed = listing.entries.to_vector();
|
|
|
+ check(parsed.length == 2, "every written entry round-trips");
|
|
|
+ check(parsed[0].name == "app" && parsed[0].version == "1.0.0+2" && parsed[0].path == "app-1.0.0+2.dcba", "entry fields survive the write verbatim");
|
|
|
+ check(parsed[0].sha512sum.equals(Wrap.byte_array(first_bytes)), "the sha512 round-trips through base64");
|
|
|
+ check(parsed[0].size == first_bytes.length, "the size round-trips");
|
|
|
+ check(parsed[1].size == 0, "an omitted size materialises as 0");
|
|
|
+ check(listing.valid_signature(Wrap.byte_array(public_key)), "the written listing verifies against its signing key");
|
|
|
+
|
|
|
+ // find() sees the written entries
|
|
|
+ check(listing.find("app", "1.0.0+2") != null && listing.find("lib-base", "1.2") != null, "find locates the written artifacts");
|
|
|
+ check(listing.find("app", "1.0.0") == null, "the version matches verbatim, not numerically");
|
|
|
+
|
|
|
+ // Byte format: compact lines, one \n per entry, signatures line last, no trailing newline
|
|
|
+ string body;
|
|
|
+ FileUtils.get_contents(listing_path, out body);
|
|
|
+ var body_lines = body.split("\n");
|
|
|
+ check(body_lines.length == 3 && body_lines[2].length > 0 && !body.has_suffix("\n"), "the listing is two entry lines plus the signatures line with no trailing newline");
|
|
|
+ check(body_lines[0].contains("\"type\":\"dcba\""), "each entry line carries the dcba type field");
|
|
|
+ check(!body_lines[0].contains(" "), "the entry line is compact JSON without spaces");
|
|
|
+ check(body_lines[2].contains("\"type\":\"signatures\""), "the signatures line terminates the listing");
|
|
|
+
|
|
|
+ // A wrong key does not verify
|
|
|
+ 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(!listing.valid_signature(Wrap.byte_array(other_public)), "a foreign key does not verify the listing");
|
|
|
+
|
|
|
+ Usm.Util.delete_tree(scratch);
|
|
|
+ }
|
|
|
+
|
|
|
+ void test_dcba_listing_writer_empty_repository() 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 listing_path = Path.build_filename(scratch, "ARTIFACTS.usml");
|
|
|
+ Usm.DcbaListingWriter.write(listing_path, new Vector<Usm.DcbaListingEntry>(), public_key, private_key);
|
|
|
+
|
|
|
+ var listing = new Usm.DcbaListing.from_stream(new DataInputStream(File.new_for_path(listing_path).read()));
|
|
|
+ check(listing.entries.count() == 0, "an empty repository writes an entry-less listing");
|
|
|
+ check(listing.valid_signature(Wrap.byte_array(public_key)), "the empty listing still verifies (empty digest signed)");
|
|
|
+
|
|
|
+ Usm.Util.delete_tree(scratch);
|
|
|
+ }
|
|
|
+
|
|
|
// ---- Granular progress reporting ----------------------------------------------
|
|
|
|
|
|
/** Whether the reports climb monotonically from start to end. */
|
|
|
@@ -2292,6 +2867,12 @@ echo "Installing libmeson-pkg.so to /usr/lib"
|
|
|
test_build_archive_restore_and_clean_retry();
|
|
|
test_build_archive_restored_incrementally();
|
|
|
test_build_failure_without_cache_propagates();
|
|
|
+ test_archive_build_skips_unchanged_tree();
|
|
|
+ test_archive_build_rearchives_changes();
|
|
|
+ test_archive_build_rearchives_modified_content();
|
|
|
+ test_archive_build_rearchives_without_sidecar();
|
|
|
+ test_build_digest_sidecar_lifecycle();
|
|
|
+ test_tree_digest_determinism();
|
|
|
}
|
|
|
catch(Error e) {
|
|
|
failures++;
|
|
|
@@ -2316,6 +2897,27 @@ echo "Installing libmeson-pkg.so to /usr/lib"
|
|
|
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 {
|
|
|
+ test_dcba_artifact_filename_parsing();
|
|
|
+ test_dcba_listing_writer_round_trip();
|
|
|
+ test_dcba_listing_writer_empty_repository();
|
|
|
+ }
|
|
|
+ catch(Error e) {
|
|
|
+ failures++;
|
|
|
+ print("FAIL dcba cli test threw: %s\n", e.message);
|
|
|
+ }
|
|
|
try {
|
|
|
test_unarchive_with_progress();
|
|
|
test_resolver_resolution_progress();
|