From a7f770071fe2082fcf525543347adbfc4cf6823b Mon Sep 17 00:00:00 2001 From: Gabriel Dos Reis Date: Mon, 20 Jul 2026 15:31:21 -0700 Subject: [PATCH] [feature]: add `archive` and `extract` subcommands of `ifc` tool --- CMakeLists.txt | 2 + ifc-driver.md | 20 +- include/ifc/file.hxx | 43 +- include/ifc/tooling.hxx | 4 +- src/file.cxx | 22 +- src/hash_win.cxx | 136 +-- src/sha256.cxx | 181 ++-- src/tools/ifc-archive.cxx | 1701 ++++++++++++++++++++++++++++++++++ src/tools/ifc-archive.hxx | 30 + src/tools/ifc.cxx | 265 ++++-- src/tools/tool-support.cxx | 205 ++++ src/tools/tool-support.hxx | 139 +++ test/CMakeLists.txt | 27 + test/archive-fixture.ps1 | 147 +++ test/archive-roundtrip.cmake | 160 ++++ test/basic.cxx | 5 +- test/n.ixx | 8 + 17 files changed, 2827 insertions(+), 268 deletions(-) create mode 100644 src/tools/ifc-archive.cxx create mode 100644 src/tools/ifc-archive.hxx create mode 100644 src/tools/tool-support.cxx create mode 100644 src/tools/tool-support.hxx create mode 100644 test/archive-fixture.ps1 create mode 100644 test/archive-roundtrip.cmake create mode 100644 test/n.ixx diff --git a/CMakeLists.txt b/CMakeLists.txt index a9f7523..cd38847 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -71,6 +71,8 @@ if(BUILD_TOOLS) add_executable( ifc src/tools/ifc.cxx + src/tools/ifc-archive.cxx + src/tools/tool-support.cxx ) add_executable(Microsoft.IFC::Tool ALIAS ifc) set_property(TARGET ifc PROPERTY EXPORT_NAME Tool) diff --git a/ifc-driver.md b/ifc-driver.md index 434bc73..1011d4b 100644 --- a/ifc-driver.md +++ b/ifc-driver.md @@ -6,13 +6,27 @@ The IFC SDK contains a command-line executable named `ifc`. It is a simple driv where: - - _cmd_ is either a built-in sucommand, or an external extension executable + - _cmd_ is either a built-in subcommand, or an external extension executable - _file1_, _file2_, _..._ are the IFC files to be acted on -At the moment, the tool supports only one built-in command: `version`. That is, the invocation +At the moment, the tool supports three built-in commands: `version`, `archive`, and `extract`. + +The invocation > `ifc` `version` _file1_ _file2_ _..._ -will display each _file_ along with the version of the of IFC Specification it was generated for. +will display each _file_ along with the version of the IFC Specification it was generated for. + +The invocation + +> `ifc` `archive` [`--name` _name_] `-o` _archive_ _file1_ _file2_ _..._ + +packages the IFC files _file1_, _file2_, _..._ into a single archive IFC file named _archive_ (an IFC file whose translation unit sort is `Archive`). Each input IFC file is embedded verbatim, retaining its own complete file structure. The archive's table of contents records, for each member, its **sort** and **canonical name** — the module name for a named module or the header-unit name for a header unit, read from that member's own unit descriptor — together with a normalized relative **filepath**, and its offset and size within the archive. A member is identified by its sort and canonical name together, so a named module and a header unit may share a name; no two members may share both. Members are stored by canonical name with sort breaking ties. Input paths containing parent traversal, or paths that normalize to the same extraction destination, are rejected. The optional `--name` gives the archive itself a canonical name. If `-o` is omitted, the first file argument names the archive to create and the rest are the IFC files to package. The inputs must be non-archive IFC files; archives cannot be nested. + +The invocation + +> `ifc` `extract` [`--force`] [`-o` _dir_] _archive_ (`--all` | _selector1_ _selector2_ _..._) + +recovers IFC files from an _archive_ created by the `archive` command. With `--all`, every contained member is extracted. Otherwise, a positional _selector_ is the canonical name of a named module, while header units are selected with the repeatable options `--quote-header` _name_ and `--angle-header` _name_. Their arguments are bare header-name payloads: `--quote-header detail/config.h` selects the member named `"detail/config.h"`, and `--angle-header vector` selects the member named ``. Keeping the delimiter form in the option avoids shell removal of quotes and interpretation of angle brackets as redirection. Each member is written to its recorded filepath beneath _dir_ (the current directory by default), recreating intermediate directories without following archive-controlled symbolic links or reparse points. Existing files are left unchanged unless `--force` is supplied, in which case each completed temporary file atomically replaces its destination. This is the inverse of `archive`, so `extract` followed by a byte comparison is a convenient way to test archive round-tripping. An IFC external extension `cmd` is any executable named `ifc-`_cmd_ that can be found via the `PATH` environment variable. Such an executable will be invoked by the `ifc` driver along with the rest of the command-line arguments it was originally invoked with. It is a convenient way to structure tooling around IFC files, making your own extension appear as if it was a built-in facility. diff --git a/include/ifc/file.hxx b/include/ifc/file.hxx index fe8d32a..7de1e81 100644 --- a/include/ifc/file.hxx +++ b/include/ifc/file.hxx @@ -121,10 +121,18 @@ namespace ifc { // Index into the scope table. enum ScopeIndex : std::uint32_t {}; + // -- SHA-256 digest whose object bytes match the 32-byte on-disk digest representation. struct SHA256Hash { - std::array value; + std::array value; // Word storage preserves the SDK's existing comparison API. }; + // -- The IFC signature precedes the stored content digest in every file image. + inline constexpr std::size_t content_hash_offset = sizeof InterfaceSignature; + + // -- IFC integrity covers every byte after the stored digest, including the rest of the header. + inline constexpr std::size_t hashed_contents_offset = content_hash_offset + sizeof(SHA256Hash); + static_assert(hashed_contents_offset == 36); + // The various sort of translation units that can be represented in an IFC file. enum class UnitSort : std::uint8_t { Source, // General source translation unit. @@ -246,8 +254,41 @@ namespace ifc { // against some external source. }; + // -- Incremental SHA-256. Feed the message through update() in any number of pieces, then call + // -- finish() exactly once. hash_bytes() below is the one-shot form of the same computation. + struct Sha256 { + // -- Stateful hashing permits bounded-memory processing of archives and other streams. + Sha256(); + // -- The Windows backend owns a bcrypt hash handle; portable state needs no special cleanup. + ~Sha256(); + // -- A hash computation has one evolving state and therefore cannot be duplicated safely. + Sha256(const Sha256&) = delete; + Sha256& operator=(const Sha256&) = delete; + + // -- Chunk boundaries carry no message semantics, allowing callers to choose bounded buffers. + void update(gsl::span bytes); + // -- Finalization consumes the pending state and is valid exactly once per computation. + SHA256Hash finish(); + + private: +#ifdef _WIN32 + void* hash_handle = nullptr; // Bcrypt owns algorithm state behind this native handle. +#else + // -- Portable compression state starts at the SHA-256 initialization vector. + std::array state { + 0x6a09e667, 0xbb67ae85, 0x3c6ef372, 0xa54ff53a, 0x510e527f, 0x9b05688c, 0x1f83d9ab, 0x5be0cd19 }; + std::array block {}; // Retains the partial SHA-256 block across update calls. + std::size_t block_length = 0; // Number of meaningful bytes currently held in block. + std::uint64_t message_length = 0; // Total bytes before padding, needed by SHA-256 finalization. +#endif + }; + + // -- One-shot compatibility entry point implemented through the same incremental backend. SHA256Hash hash_bytes(const std::byte* first, const std::byte* last); + // -- Applies the IFC integrity boundary consistently to a complete mapped file image. + SHA256Hash hash_ifc_contents(gsl::span image); + inline SHA256Hash bytes_to_hash(const std::uint8_t* first, const std::uint8_t* last) { auto byte_count = std::distance(first, last); diff --git a/include/ifc/tooling.hxx b/include/ifc/tooling.hxx index bf21241..d202fc7 100644 --- a/include/ifc/tooling.hxx +++ b/include/ifc/tooling.hxx @@ -37,7 +37,9 @@ namespace ifc::tool { // -- Base class for an ifc subcommand extension. struct Extension { - virtual Name name() const = 0; + // -- Constexpr names let builtin registries prove lookup ordering at compile time. + virtual constexpr Name name() const = 0; + // -- Each extension owns its argument policy while the driver owns dispatch and exception containment. virtual int run_with(const Arguments&) const = 0; }; diff --git a/src/file.cxx b/src/file.cxx index 15c1536..62f5fd0 100644 --- a/src/file.cxx +++ b/src/file.cxx @@ -4,18 +4,26 @@ #include namespace ifc { + SHA256Hash hash_bytes(const std::byte* first, const std::byte* last) + { + Sha256 hasher; + hasher.update({ first, static_cast(last - first) }); + return hasher.finish(); + } + + SHA256Hash hash_ifc_contents(gsl::span image) + { + IFCASSERT(image.size() >= hashed_contents_offset); + return hash_bytes(image.data() + hashed_contents_offset, image.data() + image.size()); + } + void InputIfc::validate_content_integrity(const InputIfc& file) { - // Verify integrity of ifc. To do this we know that the header content after the hash - // starts after the interface signature and the first 256 bits. - constexpr std::size_t hash_start = sizeof(InterfaceSignature); - constexpr std::size_t contents_start = hash_start + sizeof(SHA256Hash); - static_assert(contents_start == 36); // 4 bytes for Signature + 8*4 bytes for SHA2 const auto& contents = file.contents(); - auto result = hash_bytes(contents.data() + contents_start, contents.data() + contents.size()); + auto result = hash_ifc_contents(contents); auto actual_first = reinterpret_cast(result.value.data()); auto actual_last = actual_first + std::size(result.value) * 4; - auto expected_first = reinterpret_cast(&contents[hash_start]); + auto expected_first = reinterpret_cast(&contents[content_hash_offset]); auto expected_last = expected_first + sizeof(SHA256Hash); if (not std::equal(actual_first, actual_last, expected_first, expected_last)) { diff --git a/src/hash_win.cxx b/src/hash_win.cxx index 949e646..f348f73 100644 --- a/src/hash_win.cxx +++ b/src/hash_win.cxx @@ -5,113 +5,61 @@ #include #include -#include + #include -#include -#include namespace { - // Tags for catch handler. - struct OpenAlgorithmError { - NTSTATUS result; - }; - struct HashLengthPropertyError { - NTSTATUS result; - }; - struct ObjectLengthPropertyError { - NTSTATUS result; - }; + // -- Preserves a BCryptCreateHash failure without imposing a standard exception hierarchy. struct CreateHashError { - NTSTATUS result; + NTSTATUS result; // Original bcrypt status retained for a future diagnostic boundary. }; + + // -- Distinguishes an input-feeding failure from construction and finalization failures. struct HashDataError { - NTSTATUS result; + NTSTATUS result; // Original bcrypt status retained for a future diagnostic boundary. }; + + // -- Identifies failure to materialize the digest after all message bytes were accepted. struct FinishHashError { - NTSTATUS result; + NTSTATUS result; // Original bcrypt status retained for a future diagnostic boundary. }; - // A simple helper class designed to be an RAII container for the Windows crypto machinery. - class SHA256Helper { - public: - SHA256Helper() - { - digest_ntstatus(BCryptOpenAlgorithmProvider(&alg_handle_, BCRYPT_SHA256_ALGORITHM, - /*pszImplementation = */ nullptr, - /*dwFlags = */ 0)); - - DWORD cb_result = 0; - digest_ntstatus(BCryptGetProperty(alg_handle_, BCRYPT_HASH_LENGTH, - reinterpret_cast(&hash_byte_length_), - sizeof hash_byte_length_, &cb_result, - /*dwFlags = */ 0)); - - digest_ntstatus(BCryptGetProperty(alg_handle_, BCRYPT_OBJECT_LENGTH, - reinterpret_cast(&object_byte_length_), - sizeof object_byte_length_, &cb_result, - /*dwFlags = */ 0)); - } - - ~SHA256Helper() - { - if (alg_handle_ != nullptr) - { - BCryptCloseAlgorithmProvider(alg_handle_, /*dwFlags = */ 0); - } - } - - ifc::SHA256Hash hash(const std::byte* first, const std::byte* last) - { - BCRYPT_HASH_HANDLE hash_handle = nullptr; - - using ByteVector = std::vector; - ByteVector object_buf(object_byte_length_); +} - digest_ntstatus(BCryptCreateHash(alg_handle_, &hash_handle, object_buf.data(), - object_byte_length_, - /*pbSecret = */ nullptr, - /*cbSecret = */ 0, - /*dwFlags = */ 0)); - auto final_act = gsl::finally([&] { - if (hash_handle != nullptr) - { - BCryptDestroyHash(hash_handle); - } - }); - digest_ntstatus(BCryptHashData(hash_handle, - reinterpret_cast(const_cast(first)), - static_cast(std::distance(first, last)), - /*dwFlags = */ 0)); - ifc::SHA256Hash hash = {}; - // uint32_t array should map to a uint8_t[32] array - IFCASSERT(hash_byte_length_ == std::size(hash.value) * 4); - digest_ntstatus( - BCryptFinishHash(hash_handle, reinterpret_cast(hash.value.data()), hash_byte_length_, - /*dwFlags = */ 0)); - return hash; - } - - private: - template - void digest_ntstatus(NTSTATUS result) - { - // Success for an NTSTATUS is >= 0. - if (result < 0) - { - throw T{result}; - } - } +namespace ifc { + // Uses the SHA-256 pseudo-algorithm handle, so there is no provider to open or close, and lets + // bcrypt manage the hash-object memory (pbHashObject == nullptr). + Sha256::Sha256() + { + const NTSTATUS status = BCryptCreateHash(BCRYPT_SHA256_ALG_HANDLE, &hash_handle, + /*pbHashObject = */ nullptr, /*cbHashObject = */ 0, + /*pbSecret = */ nullptr, /*cbSecret = */ 0, /*dwFlags = */ 0); + if (status < 0) + throw CreateHashError{status}; + } - BCRYPT_ALG_HANDLE alg_handle_ = nullptr; - DWORD hash_byte_length_ = 0; - DWORD object_byte_length_ = 0; - }; + Sha256::~Sha256() + { + if (hash_handle != nullptr) + BCryptDestroyHash(hash_handle); + } -} // namespace + void Sha256::update(gsl::span bytes) + { + const NTSTATUS status = BCryptHashData(hash_handle, + reinterpret_cast(const_cast(bytes.data())), + static_cast(bytes.size()), /*dwFlags = */ 0); + if (status < 0) + throw HashDataError{status}; + } -namespace ifc { - SHA256Hash hash_bytes(const std::byte* first, const std::byte* last) + SHA256Hash Sha256::finish() { - SHA256Helper helper; - return helper.hash(first, last); + SHA256Hash hash = {}; + const NTSTATUS status = BCryptFinishHash(hash_handle, reinterpret_cast(hash.value.data()), + static_cast(sizeof hash.value), /*dwFlags = */ 0); + if (status < 0) + throw FinishHashError{status}; + return hash; } + } // namespace ifc diff --git a/src/sha256.cxx b/src/sha256.cxx index 76d3021..a3fc027 100644 --- a/src/sha256.cxx +++ b/src/sha256.cxx @@ -4,16 +4,13 @@ // Cross-platform Implementation of SHA256 #include +#include #include #include -#include #include -#include -#include - namespace { - // Defined values of K for SHA-256 + // -- Standard SHA-256 round constants; keeping the specified values visible aids independent auditing. constexpr uint32_t K[64] = { 0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, 0x3956c25b, 0x59f111f1, 0x923f82a4, 0xab1c5ed5, 0xd807aa98, 0x12835b01, 0x243185be, 0x550c7dc3, 0x72be5d74, 0x80deb1fe, 0x9bdc06a7, 0xc19bf174, @@ -24,63 +21,53 @@ namespace { 0x19a4c116, 0x1e376c08, 0x2748774c, 0x34b0bcb5, 0x391c0cb3, 0x4ed8aa4a, 0x5b9cca4f, 0x682e6ff3, 0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208, 0x90befffa, 0xa4506ceb, 0xbef9a3f7, 0xc67178f2}; - // Initial hash values for SHA-256 - constexpr std::array initial_hash_values{0x6a09e667, 0xbb67ae85, 0x3c6ef372, 0xa54ff53a, - 0x510e527f, 0x9b05688c, 0x1f83d9ab, 0x5be0cd19}; - - // Various functions performing operations as defined by SHA-256. + // -- SHA-256 choice primitive used in each compression round. constexpr uint32_t ch(uint32_t x, uint32_t y, uint32_t z) { return (x & y) ^ ((~x) & z); } + // -- SHA-256 majority primitive used in each compression round. constexpr uint32_t maj(uint32_t x, uint32_t y, uint32_t z) { return (x & y) ^ (x & z) ^ (y & z); } + // -- SHA-256 upper-case sigma 0 transform for the working state. uint32_t constexpr SIGMA0(uint32_t w) { return std::rotr(w, 2) xor std::rotr(w, 13) xor std::rotr(w, 22); } + // -- SHA-256 upper-case sigma 1 transform for the working state. uint32_t constexpr SIGMA1(uint32_t w) { return std::rotr(w, 6) xor std::rotr(w, 11) xor std::rotr(w, 25); } + // -- SHA-256 lower-case sigma 0 transform for message expansion. uint32_t constexpr sigma0(uint32_t w) { return std::rotr(w, 7) xor std::rotr(w, 18) xor (w >> 3); } + // -- SHA-256 lower-case sigma 1 transform for message expansion. uint32_t constexpr sigma1(uint32_t w) { return std::rotr(w, 17) xor std::rotr(w, 19) xor (w >> 10); } + // -- Shared compression boundary used by every incremental and one-shot portable hash operation. constexpr void process_chunk(std::array& hash, const std::byte* p) { // w is the message schedule array. uint32_t w[64] = {0}; for (int i = 0, j = 0; i < 16; ++i, j += 4) { - // Can't do a memcpy and then change_endianness as memcpy is not constexpr. - uint32_t b0, b1, b2, b3; - if constexpr (std::endian::native == std::endian::little) - { - b0 = static_cast(p[j]) << 24; - b1 = static_cast(p[j + 1]) << 16; - b2 = static_cast(p[j + 2]) << 8; - b3 = static_cast(p[j + 3]); - } - else - { - b0 = static_cast(p[j]); - b1 = static_cast(p[j + 1]) << 8; - b2 = static_cast(p[j + 2]) << 16; - b3 = static_cast(p[j + 3]) << 24; - } + const uint32_t b0 = static_cast(p[j]) << 24; + const uint32_t b1 = static_cast(p[j + 1]) << 16; + const uint32_t b2 = static_cast(p[j + 2]) << 8; + const uint32_t b3 = static_cast(p[j + 3]); w[i] = b0 | b1 | b2 | b3; } @@ -121,99 +108,85 @@ namespace { hash[7] += h; } - // convert between little-endian/big-endian + // -- IFC stores digest bytes in SHA-256's big-endian representation on every host architecture. constexpr void change_endianness(uint32_t& v) { // TODO use std::byteswap when C++23 targeted. v = ((v & 0xff000000) >> 24) | ((v & 0xff0000) >> 8) | ((v & 0xff00) << 8) | ((v & 0xff) << 24); } - constexpr ifc::SHA256Hash sha256(gsl::span span) +} // namespace + +namespace ifc { + Sha256::Sha256() + { + } + + Sha256::~Sha256() + { + } + + void Sha256::update(gsl::span bytes) { - ifc::SHA256Hash hash = {initial_hash_values}; - - const auto length = span.size(); - const auto base = span.data(); - // Process whole chunks first - const auto iterations = length / 64; - for (size_t iter = 0; iter < iterations; ++iter) - process_chunk(hash.value, base + iter * 64); - - /* - append a single '1' bit - append K '0' bits, where K is the minimum number >= 0 such that (L + 1 + K + 64) is a multiple of 512 - append L as a 64-bit big-endian integer, making the total post-processed length a multiple of 512 bits - This requires at least 9 bytes. If the remainder is greater than 55, a second extra chunk is needed. - */ - - // remainder is number of bytes in message past 64byte boundary (could be zero) - const auto remainder = length % 64; - - // Room for one or two chunks (as needed) - std::array v {}; // Fill with zeros - std::copy(base + iterations * 64, base + iterations * 64 + remainder, v.begin()); - v[remainder] = std::byte{0x80}; // Put 10000000 after data - // The data, the "1" bit, and zero bits are now in place. - - // Put total number of bits in message at end. - const uint64_t total_bits = length * 8; - if (remainder > 55) // Need the second block + message_length += bytes.size(); + const std::byte* p = bytes.data(); + std::size_t remaining = bytes.size(); + + // Finish any block left partly filled by a previous update. + if (block_length != 0) { - // Put number of bits at end of second block. - constexpr auto bits_count_offset = v.size() - sizeof(uint64_t); - std::byte* pbits = v.data() + bits_count_offset; - for (int i = 0; i < 8; ++i) - { - uint8_t byte = static_cast(total_bits >> (7 - i) * 8); - *pbits++ = std::byte{byte}; - } - process_chunk(hash.value, v.data()); - process_chunk(hash.value, v.data() + 64); + const std::size_t room = block.size() - block_length; + const std::size_t take = remaining < room ? remaining : room; + for (std::size_t i = 0; i < take; ++i) + block[block_length + i] = p[i]; + block_length += take; + p += take; + remaining -= take; + if (block_length < block.size()) + return; + process_chunk(state, block.data()); + block_length = 0; } - else + // Fold whole blocks straight from the input. + while (remaining >= block.size()) { - // Put number of bits at end of first block. - constexpr auto bits_count_offset = v.size()/2 - sizeof(uint64_t); - std::byte* pbits = v.data() + bits_count_offset; - for (int i = 0; i < 8; ++i) - { - uint8_t byte = static_cast(total_bits >> (7 - i) * 8); - *pbits++ = std::byte{byte}; - } - process_chunk(hash.value, v.data()); + process_chunk(state, p); + p += block.size(); + remaining -= block.size(); } + // Stash the remainder for the next update or finish. + for (std::size_t i = 0; i < remaining; ++i) + block[i] = p[i]; + block_length = remaining; + } - if constexpr (std::endian::native == std::endian::little) + SHA256Hash Sha256::finish() + { + const std::uint64_t bit_length = message_length * 8; + + // Append the '1' bit, pad with zeros, and finish with the 64-bit big-endian bit length, + // spilling into a second block when the length would not fit in the current one. + block[block_length++] = std::byte{0x80}; + if (block_length > block.size() - sizeof bit_length) { - // Convert hash bytes to proper endianness - for (auto& i : hash.value) - change_endianness(i); + while (block_length < block.size()) + block[block_length++] = std::byte{0}; + process_chunk(state, block.data()); + block_length = 0; } - + while (block_length < block.size() - sizeof bit_length) + block[block_length++] = std::byte{0}; + for (std::size_t i = 0; i < sizeof bit_length; ++i) + block[block.size() - sizeof bit_length + i] = + static_cast(bit_length >> (8 * (sizeof bit_length - 1 - i))); + process_chunk(state, block.data()); + + SHA256Hash hash; + hash.value = state; + if constexpr (std::endian::native == std::endian::little) + for (std::uint32_t& word : hash.value) + change_endianness(word); return hash; } -#ifndef NDEBUG - // Statically verify the hash is correct with two simple tests. - - // "", "E3B0C442 98FC1C14 9AFBF4C8 996FB924 27AE41E4 649B934C A495991B 7852B855"); - constexpr const std::byte test1[1] = {}; - constexpr ifc::SHA256Hash hash1 = {0x42C4B0E3, 0x141CFC98, 0xC8F4FB9A, 0x24B96F99, - 0xE441AE27, 0x4C939B64, 0x1B9995A4, 0x55B85278}; - static_assert(sha256(gsl::span(test1, test1)).value == hash1.value); - - // "a" "CA978112 CA1BBDCA FAC231B3 9A23DC4D A786EFF8 147C4E72 B9807785 AFEE48BB"); - constexpr const std::byte test2[1] = {std::byte{'a'}}; - constexpr ifc::SHA256Hash hash2 = {0x128197CA, 0xCABD1BCA, 0xB331C2FA, 0x4DDC239A, - 0xF8EF86A7, 0x724E7C14, 0x857780B9, 0xBB48EEAF}; - static_assert(sha256(gsl::span(test2, 1)).value == hash2.value); -#endif -} // namespace - -namespace ifc { - SHA256Hash hash_bytes(const std::byte* first, const std::byte* last) - { - gsl::span span(first, last); - return sha256(span); - } } // namespace ifc diff --git a/src/tools/ifc-archive.cxx b/src/tools/ifc-archive.cxx new file mode 100644 index 0000000..bd16545 --- /dev/null +++ b/src/tools/ifc-archive.cxx @@ -0,0 +1,1701 @@ +// Copyright Microsoft Corporation. +// SPDX-License-Identifier: Apache-2.0 WITH LLVM-exception + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#ifdef _WIN32 +# include +#else +# include +# include +# include +#endif + +#include "ifc/file.hxx" +#include "ifc/tooling.hxx" +#include "tool-support.hxx" +#include "ifc-archive.hxx" + +namespace { + // Bring the shared subcommand helpers (string_at, read_ifc_header, to_utf8, valid_ifc_image, + // ...) into scope so the archive internals below can name them directly. + using namespace ifc::tool; + + // -- Round `offset` up to the next multiple of `alignment`, which must be a power of two. + inline std::uint64_t align_up(std::uint64_t offset, std::uint64_t alignment) + { + return (offset + (alignment - 1)) & ~(alignment - 1); + } + + // -- The IFC on-disk scalar types have at most 4-byte alignment. Placing each embedded + // -- IFC file and the table of contents on a 4-byte boundary keeps them readable in place, + // -- without copying, on targets that forbid unaligned access. + constexpr std::uint32_t ifc_alignment = 4; + + // -- The archive table-of-contents entry is the SDK's ArchiveMember record (ifc/file.hxx). + using ifc::ArchiveMember; + + // -- One representation of the format's canonical-name-first, UnitSort-second member key. + struct ArchiveMemberKey { + std::u8string_view canonical; // Canonical name bytes in the relevant string table. + ifc::UnitSort sort; // Distinguishes translation units that legitimately share a name. + + // -- Default member order is the archive's required ordering. + std::strong_ordering operator<=>(const ArchiveMemberKey&) const = default; + }; + + // -- A staged archive member while the archive is being written. + struct ArchiveEntry { + std::u8string canonical; // Canonical name; with `sort`, the member's identity for sort/dedup. + ifc::UnitSort sort { }; // The member's translation-unit sort; part of its identity. + StringTableBuilder::Handle name_handle { }; // Canonical name, in the archive string table. + StringTableBuilder::Handle path_handle { }; // Filepath, in the archive string table. + ifc::ByteOffset offset { }; // Member IFC location within the archive. + ifc::EntitySize size { }; // Member IFC size in bytes. + + // -- Writer sorting uses the same key abstraction as reader validation. + ArchiveMemberKey key() const + { + return { canonical, sort }; + } + }; + + // -- Read the canonical name (and unit sort) recorded in the member IFC mapped at `bytes`. + // -- Return false if the member records no canonical name (its unit index is null) or the + // -- header is malformed. `bytes` must already satisfy valid_ifc_image. + bool member_canonical_name(ifc::tool::InputFile::View bytes, ifc::UnitSort& sort, std::u8string& name) + { + const ifc::Header& header = ifc_header(bytes); + sort = header.unit.sort(); + const std::uint64_t name_offset = std::to_underlying(header.unit.index()); + if (name_offset == 0) // A null TextOffset: the member records no canonical name. + return false; + const std::uint64_t table_offset = std::to_underlying(header.string_table_bytes); + const std::uint64_t table_size = std::to_underlying(header.string_table_size); + if (table_offset + table_size > bytes.size()) + return false; + const std::u8string_view table { reinterpret_cast(bytes.data() + table_offset), + static_cast(table_size) }; + const std::u8string_view canonical = string_at(table, static_cast(name_offset)); + if (canonical.empty()) + return false; + name.assign(canonical); + return true; + } + + // -- The largest byte offset representable in the 32-bit IFC on-disk fields. + constexpr std::uint64_t archive_offset_limit = std::numeric_limits::max(); + + // -- Every archive byte must be addressable by ByteOffset; this check precedes hash processing. + inline constexpr bool archive_size_fits(std::uint64_t size) + { + return size <= archive_offset_limit; + } + static_assert(archive_size_fits(archive_offset_limit)); + static_assert(not archive_size_fits(archive_offset_limit + 1)); + + // -- A member's canonical name is addressed by the index field of a UnitIndex, which is only + // -- index_precision bits wide -- narrower than a full TextOffset. An offset beyond + // -- this range would be silently truncated by the UnitIndex constructor, so the archive writer + // -- rejects it rather than mis-key a member. + constexpr unsigned unit_name_offset_bits = index_like::index_precision; + constexpr std::uint64_t max_unit_name_offset = (std::uint64_t { 1 } << unit_name_offset_bits) - 1; + + // -- Return true if `offset` fits in the index field of a member's UnitIndex name. + inline bool name_offset_fits(ifc::TextOffset offset) + { + return std::to_underlying(offset) <= max_unit_name_offset; + } + + // -- Distinguishes harmless name collisions (which may be retried) from creation failures. + enum class CreateFileResult { + Ok, // The file was created and opened. + Exists, // A filesystem entry already has the candidate name. + Error, // Creation failed for another reason. + }; + + // -- Carries a just-created native resource until NativeFile assumes unique ownership. + struct FileCreation { +#ifdef _WIN32 + HANDLE handle; // Valid only when result is Ok. +#else + int descriptor; // Valid only when result is Ok. +#endif + CreateFileResult result; // Allows candidate collisions to be retried without masking hard failures. + }; + + // -- CREATE_NEW/O_EXCL makes the filesystem, rather than randomness, arbitrate name ownership. + inline FileCreation create_native_file(const ifc::fs::path& path) + { +#ifdef _WIN32 + const HANDLE handle = CreateFileW(path.c_str(), GENERIC_READ | GENERIC_WRITE, 0, nullptr, CREATE_NEW, + FILE_ATTRIBUTE_TEMPORARY | FILE_FLAG_OPEN_REPARSE_POINT, nullptr); + if (handle != INVALID_HANDLE_VALUE) + return { handle, CreateFileResult::Ok }; + const DWORD error = GetLastError(); + return { INVALID_HANDLE_VALUE, + error == ERROR_FILE_EXISTS or error == ERROR_ALREADY_EXISTS + ? CreateFileResult::Exists : CreateFileResult::Error }; +#else + const int descriptor = ::open(path.c_str(), O_RDWR | O_CREAT | O_EXCL | O_NOFOLLOW | O_CLOEXEC, 0600); + if (descriptor >= 0) + return { descriptor, CreateFileResult::Ok }; + return { -1, errno == EEXIST ? CreateFileResult::Exists : CreateFileResult::Error }; +#endif + } + +#ifndef _WIN32 + // -- Directory-relative creation preserves containment after the path components were checked. + inline FileCreation create_native_file_at(int directory, const ifc::fs::path& name) + { + const int descriptor = ::openat(directory, name.c_str(), + O_RDWR | O_CREAT | O_EXCL | O_NOFOLLOW | O_CLOEXEC, 0600); + if (descriptor >= 0) + return { descriptor, CreateFileResult::Ok }; + return { -1, errno == EEXIST ? CreateFileResult::Exists : CreateFileResult::Error }; + } +#endif + + // -- Native I/O failures unwind through RAII; the surrounding operation supplies diagnostic context. + struct NativeFileError { + }; + + // -- One move-only owner for CloseHandle/close resources; file and directory code differ in + // -- operations, not in lifetime semantics. + struct NativeResource { +#ifdef _WIN32 + // -- Ownership starts only with a valid Windows handle. + explicit NativeResource(HANDLE value) : handle { value } + { + } +#else + // -- Ownership starts only with a valid POSIX descriptor. + explicit NativeResource(int value) : descriptor { value } + { + } +#endif + + // -- Moving transfers the sole close obligation. + NativeResource(NativeResource&& source) noexcept +#ifdef _WIN32 + : handle { source.handle } +#else + : descriptor { source.descriptor } +#endif + { +#ifdef _WIN32 + source.handle = INVALID_HANDLE_VALUE; +#else + source.descriptor = -1; +#endif + } + + // -- Traversal advances ownership without leaking the preceding directory resource. + NativeResource& operator=(NativeResource&& source) noexcept + { + if (this != &source) + { + close(); +#ifdef _WIN32 + handle = source.handle; + source.handle = INVALID_HANDLE_VALUE; +#else + descriptor = source.descriptor; + source.descriptor = -1; +#endif + } + return *this; + } + + // -- Constructor unwinding and ordinary scope exit share one cleanup path. + ~NativeResource() + { + close(); + } + +#ifdef _WIN32 + // -- Native operations need the retained Windows handle without sharing ownership. + HANDLE get() const + { + return handle; + } +#else + // -- Native operations need the retained POSIX descriptor without sharing ownership. + int get() const + { + return descriptor; + } +#endif + + // -- Idempotence supports explicit pre-publication close and destructor cleanup. + void close() + { +#ifdef _WIN32 + if (handle != INVALID_HANDLE_VALUE) + { + CloseHandle(handle); + handle = INVALID_HANDLE_VALUE; + } +#else + if (descriptor >= 0) + { + ::close(descriptor); + descriptor = -1; + } +#endif + } + + private: +#ifdef _WIN32 + HANDLE handle; // INVALID_HANDLE_VALUE denotes the moved-from or closed state. +#else + int descriptor; // -1 denotes the moved-from or closed state. +#endif + }; + + // -- Retains one native file from exclusive creation through flush, closing the path-reopen + // -- races inherent in using fstreams for temporary output. + struct NativeFile { + #ifdef _WIN32 + // -- Ownership begins only after CREATE_NEW returned a valid handle. + explicit NativeFile(HANDLE value) : resource { value } + { + } + #else + // -- Ownership begins only after O_EXCL returned a valid descriptor. + explicit NativeFile(int value) : resource { value } + { + } + #endif + + // -- Transfer permits complete temporary-file values to be returned without duplicating ownership. + NativeFile(NativeFile&&) noexcept = default; + + // -- Native writes may be partial or interrupted; callers need an all-or-failure operation. + void write(gsl::span bytes) + { + const std::byte* first = bytes.data(); + std::size_t remaining = bytes.size(); + while (remaining != 0) + { +#ifdef _WIN32 + const DWORD count = static_cast(std::min(remaining, + std::numeric_limits::max())); + DWORD written = 0; + if (not WriteFile(resource.get(), first, count, &written, nullptr) or written == 0) + throw NativeFileError { }; +#else + const ssize_t written = ::write(resource.get(), first, remaining); + if (written < 0 and errno == EINTR) + continue; + if (written <= 0) + throw NativeFileError { }; +#endif + first += written; + remaining -= written; + } + } + + // -- Zero denotes EOF; native failures throw, so callers cannot confuse the two. + std::size_t read(gsl::span bytes) + { +#ifdef _WIN32 + const DWORD count = static_cast(std::min(bytes.size(), + std::numeric_limits::max())); + DWORD received = 0; + if (not ReadFile(resource.get(), bytes.data(), count, &received, nullptr)) + throw NativeFileError { }; + return received; +#else + for (;;) + { + const ssize_t received = ::read(resource.get(), bytes.data(), bytes.size()); + if (received < 0 and errno == EINTR) + continue; + if (received < 0) + throw NativeFileError { }; + return static_cast(received); + } +#endif + } + + // -- The archive header and digest are patched only after body offsets and the hash are known. + void seek(std::uint64_t offset) + { +#ifdef _WIN32 + LARGE_INTEGER position; + position.QuadPart = static_cast(offset); + if (not SetFilePointerEx(resource.get(), position, nullptr, FILE_BEGIN)) + throw NativeFileError { }; +#else + if (::lseek(resource.get(), static_cast(offset), SEEK_SET) < 0) + throw NativeFileError { }; +#endif + } + + // -- A temporary file is never published before its completed contents reach the OS. + void flush() + { +#ifdef _WIN32 + if (not FlushFileBuffers(resource.get())) + throw NativeFileError { }; +#else + if (::fsync(resource.get()) != 0) + throw NativeFileError { }; +#endif + } + + // -- Idempotence keeps early-return cleanup and pre-commit close on the same path. + void close() + { + resource.close(); + } + + private: + NativeResource resource; // Centralizes move and close semantics shared with directories. + }; + + // -- Signals failure to establish the user-selected extraction root. + struct OutputDirectoryError { + ifc::fs::path path; // Requested root supplies the command diagnostic. + }; + + // -- Signals a link, non-directory, or native failure in an archive-controlled descendant. + struct UnsafeOutputDirectoryError { + ifc::fs::path path; // Relative member path identifies the rejected traversal. + }; + + // -- A directory reached without traversing archive-controlled links. Its native handle is + // -- retained while a member is written so checked path components cannot be replaced. + struct SecureDirectory { + // -- Directory pins have unique ownership because they enforce a live containment boundary. + SecureDirectory(const SecureDirectory&) = delete; + SecureDirectory& operator=(const SecureDirectory&) = delete; + + // -- The user-selected root is resolved once; only archive-controlled descendants reject links. + explicit SecureDirectory(const ifc::fs::path& root) + { + std::error_code ec; + ifc::fs::create_directories(root, ec); + if (ec) + throw OutputDirectoryError { root }; +#ifdef _WIN32 + const HANDLE handle = CreateFileW(root.c_str(), FILE_READ_ATTRIBUTES, FILE_SHARE_READ, nullptr, + OPEN_EXISTING, FILE_FLAG_BACKUP_SEMANTICS, nullptr); + if (handle == INVALID_HANDLE_VALUE) + throw OutputDirectoryError { root }; + NativeResource root_handle { handle }; + BY_HANDLE_FILE_INFORMATION info { }; + if (not GetFileInformationByHandle(handle, &info) + or not (info.dwFileAttributes & FILE_ATTRIBUTE_DIRECTORY)) + throw OutputDirectoryError { root }; + const DWORD required = GetFinalPathNameByHandleW(handle, nullptr, 0, + FILE_NAME_NORMALIZED | VOLUME_NAME_DOS); + if (required == 0) + throw OutputDirectoryError { root }; + std::wstring final_path(required, L'\0'); + const DWORD written = GetFinalPathNameByHandleW(handle, final_path.data(), required, + FILE_NAME_NORMALIZED | VOLUME_NAME_DOS); + if (written == 0 or written >= required) + throw OutputDirectoryError { root }; + final_path.resize(written); + path = ifc::fs::path { final_path }; + handles.push_back(std::move(root_handle)); +#else + path = ifc::fs::canonical(root, ec); + if (ec) + throw OutputDirectoryError { root }; + const int opened = ::open(path.c_str(), O_RDONLY | O_DIRECTORY | O_NOFOLLOW | O_CLOEXEC); + if (opened < 0) + throw OutputDirectoryError { root }; + handles.emplace_back(opened); +#endif + } + + // -- Retained-handle traversal prevents a checked descendant from becoming a link before commit. + SecureDirectory(const SecureDirectory& root, const ifc::fs::path& relative) : path { root.path } + { +#ifdef _WIN32 + HANDLE duplicate = INVALID_HANDLE_VALUE; + if (not DuplicateHandle(GetCurrentProcess(), root.handles.back().get(), GetCurrentProcess(), &duplicate, + 0, FALSE, DUPLICATE_SAME_ACCESS)) + throw UnsafeOutputDirectoryError { relative }; + handles.emplace_back(duplicate); + for (const ifc::fs::path& component : relative) + { + const ifc::fs::path candidate = path / component; + if (not CreateDirectoryW(candidate.c_str(), nullptr) and GetLastError() != ERROR_ALREADY_EXISTS) + throw UnsafeOutputDirectoryError { relative }; + const HANDLE handle = CreateFileW(candidate.c_str(), FILE_READ_ATTRIBUTES, FILE_SHARE_READ, nullptr, + OPEN_EXISTING, + FILE_FLAG_BACKUP_SEMANTICS | FILE_FLAG_OPEN_REPARSE_POINT, nullptr); + if (handle == INVALID_HANDLE_VALUE) + throw UnsafeOutputDirectoryError { relative }; + NativeResource child { handle }; + BY_HANDLE_FILE_INFORMATION info { }; + if (not GetFileInformationByHandle(handle, &info) + or not (info.dwFileAttributes & FILE_ATTRIBUTE_DIRECTORY) + or (info.dwFileAttributes & FILE_ATTRIBUTE_REPARSE_POINT)) + throw UnsafeOutputDirectoryError { relative }; + handles.push_back(std::move(child)); + path = candidate; + } +#else + const int duplicate = ::fcntl(root.handles.back().get(), F_DUPFD_CLOEXEC, 0); + if (duplicate < 0) + throw UnsafeOutputDirectoryError { relative }; + handles.emplace_back(duplicate); + for (const ifc::fs::path& component : relative) + { + const ifc::fs::path candidate = path / component; + if (::mkdirat(handles.back().get(), component.c_str(), 0700) != 0 and errno != EEXIST) + throw UnsafeOutputDirectoryError { relative }; + const int opened = ::openat(handles.back().get(), component.c_str(), + O_RDONLY | O_DIRECTORY | O_NOFOLLOW | O_CLOEXEC); + if (opened < 0) + throw UnsafeOutputDirectoryError { relative }; + NativeResource next { opened }; + path = candidate; + handles.back() = std::move(next); + } +#endif + } + + // -- Win32 lacks an openat equivalent, so the pinned handle chain accompanies this resolved path. + const ifc::fs::path& pathname() const + { + return path; + } + +#ifndef _WIN32 + // -- POSIX *at operations keep creation and commit relative to the verified directory inode. + int native_descriptor() const + { + return handles.back().get(); + } +#endif + + private: + ifc::fs::path path; // Stable Windows spelling and user-facing diagnostic context. + // Windows retains every pinned component; POSIX advances one verified leaf descriptor. + std::vector handles; + }; + + // -- Encodes whether destruction must remove a temporary filesystem entry. + enum class TemporaryFileState { + Armed, // The temporary file exists and must be removed on destruction. + Committed, // The temporary file was moved to its destination. + }; + + // -- Records whether cleanup must remain relative to a verified directory handle. + enum class TemporaryFileLocation { + Path, // Address the file by its full pathname. + Directory, // Address the file relative to a retained directory handle. + }; + + // -- Makes overwrite authority explicit at the commit boundary rather than during creation. + enum class ExistingFile { + Reject, // Leave an existing destination unchanged. + Replace, // Atomically replace an existing destination. + }; + + // -- Header-unit identity includes the C++ header-name delimiter form, which shells cannot + // -- preserve reliably when it is written literally on a command line. + enum class HeaderNameForm { + Quote, // Select the header unit whose canonical name has "..." form. + Angle, // Select the header unit whose canonical name has <...> form. + }; + + // -- Retains the user's bare spelling for diagnostics and the synthesized IFC key for lookup. + struct HeaderRequest { + ifc::tool::StringView spelling; // Undelimited command-line payload. + std::u8string canonical; // Exact delimited canonical name recorded by the IFC. + HeaderNameForm form; // Controls delimiter synthesis and form-specific diagnostics. + }; + + // -- Separating delimiter form from the argument avoids shell quote removal and redirection syntax. + std::u8string canonical_header_name(HeaderNameForm form, ifc::tool::StringView spelling) + { + std::u8string canonical = to_utf8(spelling); + if (canonical.empty() or canonical.find(u8'\n') != std::u8string::npos + or canonical.find(u8'\r') != std::u8string::npos) + return { }; + + char8_t open = u8'"'; + char8_t close = u8'"'; + if (form == HeaderNameForm::Quote) + { + if (canonical.find(u8'"') != std::u8string::npos) + return { }; + } + else + { + if (canonical.find(u8'>') != std::u8string::npos) + return { }; + open = u8'<'; + close = u8'>'; + } + canonical.insert(canonical.begin(), open); + canonical.push_back(close); + return canonical; + } + + // -- Separates the expected no-overwrite collision from actual commit failures. + enum class CommitFileResult { + Ok, // The destination now names the completed file. + Exists, // Replacement was forbidden and the destination exists. + }; + + // -- Randomness reduces retries; exclusive creation, not the token, provides collision safety. + ifc::fs::path unique_temp_path(const ifc::fs::path& base) + { + std::random_device rng; + const std::uint64_t token = (static_cast(rng()) << 32) ^ rng(); + ifc::fs::path temp = base; + temp += STR("."); + temp += std::to_string(token); + temp += STR(".tmp"); + return temp; + } + + // -- Identifies failure to establish an exclusively-created temporary-file invariant. + struct TemporaryFileCreateError { + ifc::fs::path path; // Requested destination supplies diagnostic context. + }; + + // -- A successful exclusive creation paired with the candidate name that won the race. + struct TemporaryFileCreation { + NativeFile file; // Already owns the exclusively-created native file. + ifc::fs::path name; // Candidate spelling used for cleanup and publication. + }; + + // -- Candidate collisions are expected; every other creation failure terminates the retry loop. + template + TemporaryFileCreation create_unique_temporary_file(const ifc::fs::path& base, + const ifc::fs::path& diagnostic, Create create) + { + for (unsigned attempt = 0; attempt != 128; ++attempt) + { + const ifc::fs::path candidate = unique_temp_path(base); + const FileCreation created = create(candidate); + if (created.result == CreateFileResult::Ok) + { +#ifdef _WIN32 + return { NativeFile { created.handle }, candidate }; +#else + return { NativeFile { created.descriptor }, candidate }; +#endif + } + if (created.result != CreateFileResult::Exists) + break; + } + throw TemporaryFileCreateError { diagnostic }; + } + + // -- Unexpected publication failures unwind while TemporaryFile still owns cleanup responsibility. + struct TemporaryFileCommitError { + ifc::fs::path path; // Destination supplies diagnostic context at the command boundary. + }; + + // -- Complete construction state assembled before TemporaryFile assumes cleanup responsibility. + struct TemporaryFileData { + NativeFile file; // Already owns a successfully-created native file. + ifc::fs::path path; // Full pathname required by Win32 and diagnostics. + ifc::fs::path name; // POSIX name relative to the verified parent. + TemporaryFileLocation location; // Determines race-free cleanup addressing. +#ifndef _WIN32 + int directory_descriptor; // Non-owning; the source SecureDirectory outlives TemporaryFile. +#endif + }; + + // -- Archive output uses a sibling so the final rename stays on one filesystem. + TemporaryFileData create_temporary_file(const ifc::fs::path& base) + { + TemporaryFileCreation created = create_unique_temporary_file(base, base, + [](const ifc::fs::path& candidate) { return create_native_file(candidate); }); +#ifdef _WIN32 + return { std::move(created.file), std::move(created.name), { }, TemporaryFileLocation::Path }; +#else + return { std::move(created.file), std::move(created.name), { }, TemporaryFileLocation::Path, -1 }; +#endif + } + + // -- Extraction creates relative to the verified parent so pathname races cannot escape it. + TemporaryFileData create_temporary_file(const SecureDirectory& directory, const ifc::fs::path& base) + { + TemporaryFileCreation created = create_unique_temporary_file(base, directory.pathname() / base, + [&directory](const ifc::fs::path& candidate) + { +#ifdef _WIN32 + return create_native_file(directory.pathname() / candidate); +#else + return create_native_file_at(directory.native_descriptor(), candidate); +#endif + }); +#ifdef _WIN32 + return { std::move(created.file), directory.pathname() / created.name, std::move(created.name), + TemporaryFileLocation::Directory }; +#else + return { std::move(created.file), directory.pathname() / created.name, std::move(created.name), + TemporaryFileLocation::Directory, directory.native_descriptor() }; +#endif + } + + // -- Couples an exclusively-created native file with fail-safe cleanup and atomic publication. + struct TemporaryFile { + // -- Construction either establishes exclusive sibling output or throws without an artifact. + explicit TemporaryFile(const ifc::fs::path& base) : TemporaryFile { create_temporary_file(base) } + { + } + + // -- Construction remains relative to the verified parent for the object's entire lifetime. + TemporaryFile(const SecureDirectory& directory, const ifc::fs::path& base) + : TemporaryFile { create_temporary_file(directory, base) } + { + } + + // -- Every failed write or commit leaves no partial output artifact. + ~TemporaryFile() + { + file.close(); + if (state == TemporaryFileState::Armed) + { +#ifndef _WIN32 + if (location == TemporaryFileLocation::Directory) + { + ::unlinkat(directory_descriptor, name.c_str(), 0); + return; + } +#endif + std::error_code ec; + ifc::fs::remove(path, ec); + } + } + + // -- Archive publication occurs only after flush and uses one atomic filesystem operation. + CommitFileResult commit_to(const ifc::fs::path& destination, ExistingFile existing) + { + prepare_commit(); +#ifdef _WIN32 + return commit_path(destination, existing); +#else + if (existing == ExistingFile::Replace) + { + if (::rename(path.c_str(), destination.c_str()) != 0) + throw TemporaryFileCommitError { destination }; + } + else + { + if (::link(path.c_str(), destination.c_str()) != 0) + { + if (errno == EEXIST) + return CommitFileResult::Exists; + throw TemporaryFileCommitError { destination }; + } + if (::unlink(path.c_str()) != 0) + throw TemporaryFileCommitError { destination }; + } +#endif + state = TemporaryFileState::Committed; + return CommitFileResult::Ok; + } + + // -- Extraction preserves containment through commit and enforces overwrite authority atomically. + CommitFileResult commit_in(const SecureDirectory& directory, const ifc::fs::path& destination, + ExistingFile existing) + { + prepare_commit(); +#ifdef _WIN32 + return commit_path(directory.pathname() / destination, existing); +#else + if (existing == ExistingFile::Replace) + { + if (::renameat(directory.native_descriptor(), name.c_str(), directory.native_descriptor(), + destination.c_str()) != 0) + throw TemporaryFileCommitError { directory.pathname() / destination }; + state = TemporaryFileState::Committed; + } + else + { + if (::linkat(directory.native_descriptor(), name.c_str(), directory.native_descriptor(), + destination.c_str(), 0) != 0) + { + if (errno == EEXIST) + return CommitFileResult::Exists; + throw TemporaryFileCommitError { directory.pathname() / destination }; + } + // The destination is committed. Leave the temp armed so its destructor removes + // the now-redundant link through this retained directory handle. + } +#endif + return CommitFileResult::Ok; + } + + // -- Writers receive native I/O without gaining access to cleanup state. + NativeFile& output() + { + return file; + } + + // -- Diagnostics may name the temp file without gaining mutation authority. + const ifc::fs::path& pathname() const + { + return path; + } + + private: + // -- Publication starts only after completed bytes are flushed and the writer releases its handle. + void prepare_commit() + { + file.flush(); + file.close(); + } + +#ifdef _WIN32 + // -- Both archive and extraction publication use the same Windows replacement policy. + CommitFileResult commit_path(const ifc::fs::path& destination, ExistingFile existing) + { + const DWORD flags = MOVEFILE_WRITE_THROUGH + | (existing == ExistingFile::Replace ? MOVEFILE_REPLACE_EXISTING : 0); + if (not MoveFileExW(path.c_str(), destination.c_str(), flags)) + { + const DWORD error = GetLastError(); + if (error == ERROR_FILE_EXISTS or error == ERROR_ALREADY_EXISTS) + return CommitFileResult::Exists; + throw TemporaryFileCommitError { destination }; + } + state = TemporaryFileState::Committed; + return CommitFileResult::Ok; + } +#endif + + // -- Assume cleanup responsibility only after every construction field is available. + explicit TemporaryFile(TemporaryFileData&& data) + : file { std::move(data.file) }, path { std::move(data.path) }, name { std::move(data.name) }, + location { data.location } +#ifndef _WIN32 + , directory_descriptor { data.directory_descriptor } +#endif + { + } + + NativeFile file; // Prevents replacement between exclusive creation and completed write. + ifc::fs::path path; // Required by Win32 commit APIs and useful in diagnostics. + ifc::fs::path name; // Keeps POSIX cleanup and commit relative to the verified parent. + TemporaryFileState state = TemporaryFileState::Armed; // Construction always creates a cleanup obligation. + TemporaryFileLocation location; // Selects race-free cleanup addressing. +#ifndef _WIN32 + int directory_descriptor = -1; // Non-owning descriptor; SecureDirectory outlives this file. +#endif + }; + + // -- A forward-only cursor over the archive output stream that tracks the running write + // -- position, so member offsets are discovered as content is streamed out. + struct WriteCursor { + NativeFile& out; // Retained file whose pathname cannot be substituted mid-write. + std::uint64_t position = 0; // 64-bit accounting precedes every narrowing to IFC offsets. + + // -- Native errors unwind immediately; successful writes advance exact layout accounting. + void write(const std::byte* data, std::uint64_t n) + { + out.write({ data, static_cast(n) }); + position += n; + } + // -- Zero filling makes alignment gaps deterministic because the archive hash covers them. + void pad_to(std::uint64_t target) + { + static constexpr std::array zero { }; + while (position < target) + write(zero.data(), std::min(target - position, zero.size())); + } + // -- Centralized alignment keeps every in-place IFC structure safely readable. + void pad_to_alignment(std::uint64_t alignment) + { + pad_to(align_up(position, alignment)); + } + + }; + + // -- Identifies a member within an archive: its canonical name together with its unit sort. + // -- A named module and a header unit can share a name, so the sort is part of the identity. + struct InternedMemberIdentity { + StringTableBuilder::Handle name; // Canonical name, in the archive string table. + ifc::UnitSort sort; // The member's translation-unit sort. + + // -- Any stable order suffices here; the set enforces uniqueness before offsets are resolved. + std::strong_ordering operator<=>(const InternedMemberIdentity&) const = default; + }; + + // -- Records whether the first member has established the archive architecture. + enum class ArchitectureState { + Unset, // No member has contributed an architecture. + Set, // At least one member has contributed an architecture. + }; + + // -- The accumulating state of an archive under construction: the string table being built, + // -- the staged table of contents, the member identities already seen + // -- (for uniqueness), and the common target architecture (Unknown once members disagree). + struct ArchiveContents { + StringTableBuilder strings; // Interned member names, filepaths, and the archive name. + std::vector entries; // Staged members, sorted by canonical name then UnitSort. + std::set used_names; // Interned identities already taken, for uniqueness. + std::set used_paths; // Normalized extraction paths already taken. + ifc::Architecture arch = ifc::Architecture::Unknown; // Common member architecture, or Unknown if they differ. + ArchitectureState arch_state = ArchitectureState::Unset; // Controls first-member initialization. + }; + + // -- Mapping one input at a time bounds address-space use; identity, extraction path, and IFC + // -- limits are validated before the member's verbatim bytes enter the archive. + bool stage_member(ifc::tool::StringView input, WriteCursor& cursor, ArchiveContents& contents) + { + ifc::fs::path path { input }; + try + { + ifc::tool::InputFile file { path.native() }; + const ifc::tool::InputFile::View bytes = file.contents(); + + if (not valid_ifc_image(bytes)) + { + IFC_ERR << path.native() << STR(" is not an IFC file") << std::endl; + return false; + } + + const ifc::Header& header = ifc_header(bytes); + if (header.unit.sort() == ifc::UnitSort::Archive) + { + IFC_ERR << path.native() << STR(" is itself an archive; archives cannot be nested") << std::endl; + return false; + } + + ifc::UnitSort sort { }; + std::u8string canonical; + if (not member_canonical_name(bytes, sort, canonical)) + { + IFC_ERR << path.native() << STR(": IFC file has no canonical name and cannot be archived") + << std::endl; + return false; + } + // A member is identified by its canonical name together with its unit sort -- a named + // module and a header unit can share a name -- so that pair must be unique in the archive. + // Interning dedups by bytes, so an already-seen name yields an already-seen handle. + const StringTableBuilder::Handle name_handle = contents.strings.intern(canonical); + if (not contents.used_names.insert({ name_handle, sort }).second) + { + IFC_ERR << path.native() + << STR(": another member already has this canonical name and unit sort") << std::endl; + return false; + } + + const ifc::fs::path stored_path = safe_relative(path.generic_u8string()); + if (stored_path.empty()) + { + IFC_ERR << path.native() << STR(": filepath cannot be represented safely in an archive") << std::endl; + return false; + } + const StringTableBuilder::Handle path_handle = contents.strings.intern(stored_path.generic_u8string()); + if (not contents.used_paths.insert(path_handle).second) + { + IFC_ERR << path.native() << STR(": another member extracts to the same filepath") << std::endl; + return false; + } + + const std::uint64_t offset = align_up(cursor.position, ifc_alignment); + if (not archive_size_fits(offset + bytes.size())) + { + IFC_ERR << path.native() << STR(": archive would exceed the 32-bit IFC offset limit") << std::endl; + return false; + } + + ArchiveEntry entry { + .canonical = std::move(canonical), + .sort = sort, + .name_handle = name_handle, + .path_handle = path_handle, + .offset = ifc::ByteOffset { static_cast(offset) }, + .size = ifc::EntitySize { static_cast(bytes.size()) }, + }; + + cursor.pad_to(offset); + cursor.write(bytes.data(), bytes.size()); + contents.entries.push_back(std::move(entry)); + + if (contents.arch_state == ArchitectureState::Unset) + { + contents.arch = header.arch; + contents.arch_state = ArchitectureState::Set; + } + else if (header.arch != contents.arch) + { + contents.arch = ifc::Architecture::Unknown; + } + return true; + } + catch (const ifc::tool::AccessError&) + { + IFC_ERR << path.native() << STR(": couldn't open file") << std::endl; + return false; + } + catch (const ifc::tool::RegularFileError&) + { + IFC_ERR << path.native() << STR(": not a regular file") << std::endl; + return false; + } + catch (const ifc::tool::FileMappingError&) + { + IFC_ERR << path.native() << STR(": couldn't memory-map file") << std::endl; + return false; + } + } + + // -- Build the (deduplicated, suffix-shared) string table and write it through `cursor`, + // -- returning the byte offset at which it was written. + std::uint64_t write_string_table(WriteCursor& cursor, StringTableBuilder& strings) + { + strings.build(); + const gsl::span table = strings.bytes(); + cursor.pad_to_alignment(ifc_alignment); + const std::uint64_t offset = cursor.position; + cursor.write(table.data(), table.size()); + return offset; + } + + // -- Write the table of contents -- one ArchiveMember per staged member, in the given order + // -- (sorted by canonical name, then unit sort) -- through `cursor`, returning its byte offset. + std::uint64_t write_table_of_contents(WriteCursor& cursor, const std::vector& entries, + const StringTableBuilder& strings) + { + cursor.pad_to_alignment(ifc_alignment); + const std::uint64_t offset = cursor.position; + for (const ArchiveEntry& entry : entries) + { + const ArchiveMember member { + ifc::UnitIndex { strings.offset(entry.name_handle), entry.sort }, + strings.offset(entry.path_handle), + entry.offset, + entry.size, + }; + cursor.write(reinterpret_cast(&member), sizeof member); + } + return offset; + } + + // -- Assemble the archive header from the finished contents and the resolved region offsets. + // -- The content hash is left zero here and patched in later (see finalize_content_hash). + ifc::Header make_archive_header(const ArchiveContents& contents, StringTableBuilder::Handle archive_name, + std::uint64_t string_table_offset, std::uint64_t toc_offset) + { + // The whole struct -- padding included -- is written to the archive and covered by the + // content hash, so every byte must be deterministic. Value-initialization would zero the + // named members but leave padding bytes indeterminate; memset zeroes the padding too. + ifc::Header header; + std::memset(&header, 0, sizeof header); + header.version = ifc::CurrentFormatVersion; + header.abi = ifc::Abi { }; + header.arch = contents.arch; + header.cplusplus = ifc::CPlusPlus { }; + header.string_table_bytes = ifc::ByteOffset { static_cast(string_table_offset) }; + header.string_table_size = ifc::Cardinality { static_cast(contents.strings.bytes().size()) }; + header.unit = ifc::UnitIndex { contents.strings.offset(archive_name), ifc::UnitSort::Archive }; + header.src_path = ifc::TextOffset { 0 }; + header.global_scope = ifc::ScopeIndex { 0 }; + header.toc = ifc::ByteOffset { static_cast(toc_offset) }; + header.partition_count = ifc::Cardinality { static_cast(contents.entries.size()) }; + header.internal_partition = false; + return header; + } + + // -- Write the body of the archive IFC -- everything but the content hash -- into `out`. Each + // -- input IFC is mapped, validated, and streamed straight in (and released) one at a time, so + // -- the whole archive is never held in memory and its size need not be known in advance. The + // -- header, whose fields reference offsets discovered while writing, is filled in last via a + // -- seek back to the start. Return true on success. + bool write_archive_body(NativeFile& out, const ifc::fs::path& temp_path, + const std::vector& inputs, ifc::tool::StringView archive_name) + { + constexpr std::uint64_t signature_size = sizeof ifc::InterfaceSignature; + + WriteCursor cursor { out }; + // Write the signature and reserve space for the header, which is patched in at the end. + cursor.write(reinterpret_cast(ifc::InterfaceSignature), signature_size); + cursor.pad_to(signature_size + sizeof(ifc::Header)); + + ArchiveContents contents; + contents.entries.reserve(inputs.size()); + const StringTableBuilder::Handle archive_name_handle = contents.strings.intern(to_utf8(archive_name)); + + for (const ifc::tool::StringView& input : inputs) + if (not stage_member(input, cursor, contents)) + return false; + + std::ranges::sort(contents.entries, { }, &ArchiveEntry::key); + + const std::uint64_t string_table_offset = write_string_table(cursor, contents.strings); + + // A member's canonical-name offset is stored in the index field of its UnitIndex (and the + // archive's own name in the header's unit index), which is narrower than a full TextOffset. + // Reject the archive rather than let the UnitIndex constructor silently truncate an offset + // that doesn't fit and mis-key a member. + if (not name_offset_fits(contents.strings.offset(archive_name_handle)) + or std::ranges::any_of(contents.entries, [&contents](const ArchiveEntry& entry) + { + return not name_offset_fits(contents.strings.offset(entry.name_handle)); + })) + { + IFC_ERR << STR("archive: a canonical-name offset exceeds the ") << unit_name_offset_bits + << STR("-bit range addressable by a member entry") << std::endl; + return false; + } + + const std::uint64_t toc_offset = write_table_of_contents(cursor, contents.entries, contents.strings); + + if (not archive_size_fits(cursor.position)) + { + IFC_ERR << STR("archive: total size exceeds the 32-bit IFC offset limit") << std::endl; + return false; + } + + const ifc::Header header = make_archive_header(contents, archive_name_handle, string_table_offset, toc_offset); + out.seek(signature_size); + out.write({ reinterpret_cast(&header), sizeof header }); + out.flush(); + return true; + } + + // -- Compute the content hash of the finished archive at `path` and patch it into the header, + // -- through a single read-write handle: the bytes after the hash field are streamed through an + // -- incremental hasher (never mapping the whole file), then the digest is written back into the + // -- hash field. One open, so there is no verify-then-write gap. Return true on success. + void finalize_content_hash(NativeFile& file) + { + // Read the archive back in bounded chunks so a large file is never held in memory at once. + constexpr std::size_t hash_chunk_bytes = 64 * 1024; + + file.seek(ifc::hashed_contents_offset); + + ifc::Sha256 hasher; + std::vector buffer(hash_chunk_bytes); + for (;;) + { + const std::size_t size = file.read(buffer); + if (size == 0) + break; + hasher.update({ buffer.data(), size }); + } + const ifc::SHA256Hash hash = hasher.finish(); + + file.seek(ifc::content_hash_offset); + file.write({ reinterpret_cast(hash.value.data()), sizeof hash.value }); + file.flush(); + } + + // -- Create the archive IFC named `output_path` from the IFC files named by `inputs`. The + // -- archive is built in a sibling temporary file and atomically renamed into place, so a + // -- failure leaves no partial output and the output may even be one of the inputs. Return + // -- true on success. + bool create_archive(const ifc::fs::path& output_path, const std::vector& inputs, + ifc::tool::StringView archive_name) + try + { + TemporaryFile temp { output_path }; + if (not write_archive_body(temp.output(), temp.pathname(), inputs, archive_name)) + return false; + finalize_content_hash(temp.output()); + temp.commit_to(output_path, ExistingFile::Replace); + return true; + } + catch (const TemporaryFileCreateError&) + { + IFC_ERR << output_path.native() << STR(": couldn't create archive") << std::endl; + return false; + } + catch (const NativeFileError&) + { + IFC_ERR << output_path.native() << STR(": couldn't write archive") << std::endl; + return false; + } + catch (const TemporaryFileCommitError&) + { + IFC_ERR << output_path.native() << STR(": couldn't replace the output with the new archive") << std::endl; + return false; + } + + // -- Print how to invoke the archive subcommand. + inline void print_archive_usage() + { + IFC_ERR << STR("ifc archive usage:\n") + << STR("\tifc archive [--name ] -o ...\n") + << STR("\tifc archive [--name ] ...\n"); + } + + // -- An opened archive IFC: the whole image mapped once (and hash-verified), with the member + // -- table and string table viewed in place within it. + struct ArchiveReader { + ifc::tool::InputFile::View image; // Keeps every validated view tied to the same hashed bytes. + gsl::span members; // Formed only after extent and alignment validation. + std::u8string_view string_table; // Non-owning text remains valid while image stays mapped. + + // -- One decoder enforces the archive string-table bounds and termination policy. + std::u8string_view text(ifc::TextOffset offset) const + { + return string_at(string_table, std::to_underlying(offset)); + } + // -- Lookup and validation must compare the same canonical-name representation. + std::u8string_view canonical(const ArchiveMember& member) const + { + return text(ifc::TextOffset { std::to_underlying(member.name.index()) }); + } + // -- Extraction consumes only path bytes validated during open_archive(). + std::u8string_view path(const ArchiveMember& member) const + { + return text(member.path); + } + + // -- Reader ordering and identity validation share the writer's key representation. + ArchiveMemberKey key(const ArchiveMember& member) const + { + return { canonical(member), member.name.sort() }; + } + + // -- Metadata validation establishes these extents before extraction requests a view. + ifc::tool::InputFile::View member_image(const ArchiveMember& member) const + { + return image.subspan(std::to_underlying(member.offset), std::to_underlying(member.size)); + } + + // -- Path normalization is centralized so validation and extraction cannot disagree. + ifc::fs::path relative_path(const ArchiveMember& member) const + { + return safe_relative(path(member)); + } + + // -- The validated ToC ordering makes all equal canonical names one contiguous range. + auto members_named(std::u8string_view name) const + { + return std::ranges::equal_range(members, name, std::ranges::less { }, + [this](const ArchiveMember& member) { return canonical(member); }); + } + + // -- Header selectors require an exact canonical-name and UnitSort match. + const ArchiveMember* member(const ArchiveMemberKey& wanted) const + { + const auto found = std::ranges::lower_bound(members, wanted, { }, + [this](const ArchiveMember& candidate) { return key(candidate); }); + if (found == members.end() or key(*found) != wanted) + return nullptr; + return std::addressof(*found); + } + }; + + // -- Orders archive byte ranges by start so overlap checks and set neighbors share one model. + struct ArchiveExtent { + std::uint64_t begin; // First byte occupied by the represented region. + std::uint64_t end; // One-past-the-last byte occupied by the represented region. + + // -- Start-first ordering makes set neighbors the only possible overlapping ranges. + std::strong_ordering operator<=>(const ArchiveExtent&) const = default; + }; + + // -- Metadata and member payloads must denote disjoint ranges before views are formed. + inline bool overlaps(const ArchiveExtent& first, const ArchiveExtent& second) + { + return first.begin < second.end and second.begin < first.end; + } + + // -- Archive recursion and values outside the specified UnitSort domain are never members. + inline bool valid_member_sort(ifc::UnitSort sort) + { + return sort < ifc::UnitSort::Count and sort != ifc::UnitSort::Archive; + } + + // -- Positional selectors are reserved for C++ named modules; header units carry explicit form. + inline bool named_module_sort(ifc::UnitSort sort) + { + return sort == ifc::UnitSort::Primary or sort == ifc::UnitSort::Partition; + } + + // -- Verify the mapped archive `bytes` against the content hash stored in `header` -- the + // -- SHA-256 over the bytes following the hash field, matching how the archive was finalized. + // -- `label` names the file in diagnostics. Return true if the hash matches; a mismatch means + // -- the archive is corrupt or has been tampered with. + bool verify_content_hash(ifc::tool::InputFile::View bytes, const ifc::Header& header, ifc::tool::StringView label) + { + if (bytes.size() < ifc::hashed_contents_offset) + { + IFC_ERR << label << STR(" is truncated or corrupted") << std::endl; + return false; + } + const ifc::SHA256Hash computed = ifc::hash_ifc_contents(bytes); + if (computed.value != header.content_hash.value) + { + IFC_ERR << label << STR(": content hash mismatch; the archive is corrupt or has been tampered with") + << std::endl; + return false; + } + return true; + } + + // -- Validate the mapped archive image `bytes`: check the signature and header, verify the + // -- content hash, and locate the member table and string table within it. `label` names the + // -- file in diagnostics. On success, point `reader` at those regions (all viewing into + // -- `bytes`) and return true. + bool open_archive(ifc::tool::InputFile::View bytes, ifc::tool::StringView label, ArchiveReader& reader) + { + if (not archive_size_fits(bytes.size())) + { + IFC_ERR << label << STR(" exceeds the 32-bit IFC file-size limit") << std::endl; + return false; + } + if (not valid_ifc_image(bytes)) + { + IFC_ERR << label << STR(" is not an IFC file") << std::endl; + return false; + } + const ifc::Header& header = ifc_header(bytes); + if (header.unit.sort() != ifc::UnitSort::Archive) + { + IFC_ERR << label << STR(" is not an archive IFC file") << std::endl; + return false; + } + // Verify integrity over the very bytes we are about to read, before trusting any offsets. + if (not verify_content_hash(bytes, header, label)) + return false; + if (header.version < ifc::MinimumFormatVersion or header.version > ifc::CurrentFormatVersion) + { + IFC_ERR << label << STR(" has an unsupported IFC format version") << std::endl; + return false; + } + + const std::uint64_t archive_size = bytes.size(); + constexpr std::uint64_t header_end = sizeof ifc::InterfaceSignature + sizeof(ifc::Header); + const std::uint64_t toc_offset = std::to_underlying(header.toc); + const std::size_t member_count = std::to_underlying(header.partition_count); + if (toc_offset % alignof(ArchiveMember) != 0 or toc_offset > archive_size + or member_count > (archive_size - toc_offset) / sizeof(ArchiveMember)) + { + IFC_ERR << label << STR(" has a truncated or corrupt table of contents") << std::endl; + return false; + } + const std::uint64_t toc_size = member_count * sizeof(ArchiveMember); + const std::uint64_t toc_end = toc_offset + toc_size; + const ArchiveExtent toc_extent { toc_offset, toc_end }; + const std::uint64_t string_offset = std::to_underlying(header.string_table_bytes); + const std::uint64_t string_size = std::to_underlying(header.string_table_size); + if (string_offset > archive_size or string_size > archive_size - string_offset) + { + IFC_ERR << label << STR(" has a truncated or corrupt string table") << std::endl; + return false; + } + const std::uint64_t string_end = string_offset + string_size; + const ArchiveExtent string_extent { string_offset, string_end }; + if (toc_offset < header_end or string_offset < header_end + or overlaps(toc_extent, string_extent)) + { + IFC_ERR << label << STR(" has overlapping archive metadata") << std::endl; + return false; + } + + reader.image = bytes; + reader.members = { reinterpret_cast(bytes.data() + toc_offset), member_count }; + reader.string_table = { reinterpret_cast(bytes.data() + string_offset), + static_cast(string_size) }; + + if (reader.string_table.empty() or reader.string_table.front() != u8'\0') + { + IFC_ERR << label << STR(" has a malformed string table") << std::endl; + return false; + } + const ifc::TextOffset archive_name { std::to_underlying(header.unit.index()) }; + if (not index_like::null(archive_name) and reader.text(archive_name).empty()) + { + IFC_ERR << label << STR(" has an invalid archive name") << std::endl; + return false; + } + + std::set used_paths; + std::set member_extents; + for (std::size_t index = 0; index != reader.members.size(); ++index) + { + const ArchiveMember& member = reader.members[index]; + const ArchiveMemberKey key = reader.key(member); + const std::u8string_view stored_path = reader.path(member); + if (not valid_member_sort(key.sort) or key.canonical.empty() or stored_path.empty()) + { + IFC_ERR << label << STR(" has an invalid archive member identity") << std::endl; + return false; + } + if (index != 0 and not (reader.key(reader.members[index - 1]) < key)) + { + IFC_ERR << label << STR(" has an unsorted or duplicate table of contents") << std::endl; + return false; + } + + const ifc::fs::path relative = reader.relative_path(member); + if (relative.empty() or relative.generic_u8string() != stored_path + or not used_paths.insert(stored_path).second) + { + IFC_ERR << label << STR(" has an invalid or duplicate member filepath") << std::endl; + return false; + } + + const std::uint64_t member_offset = std::to_underlying(member.offset); + const std::uint64_t member_size = std::to_underlying(member.size); + const ArchiveExtent extent { member_offset, member_offset + member_size }; + if (member_offset % ifc_alignment != 0 or member_offset < header_end + or member_offset > archive_size or member_size > archive_size - member_offset + or overlaps(extent, toc_extent) or overlaps(extent, string_extent)) + { + IFC_ERR << label << STR(" has an invalid archive member extent") << std::endl; + return false; + } + const auto next = member_extents.lower_bound(extent); + if ((next != member_extents.end() and overlaps(*next, extent)) + or (next != member_extents.begin() and overlaps(*std::prev(next), extent))) + { + IFC_ERR << label << STR(" has overlapping archive member extents") << std::endl; + return false; + } + member_extents.insert(next, extent); + + const ifc::tool::InputFile::View member_image = reader.member_image(member); + if (not valid_ifc_image(member_image)) + { + IFC_ERR << label << STR(" contains a malformed IFC member") << std::endl; + return false; + } + ifc::UnitSort embedded_sort { }; + std::u8string embedded_name; + if (not member_canonical_name(member_image, embedded_sort, embedded_name) + or embedded_sort != key.sort or embedded_name != key.canonical) + { + IFC_ERR << label << STR(" has a member identity inconsistent with its IFC content") << std::endl; + return false; + } + } + return true; + } + + // -- Print how to invoke the extract subcommand. + inline void print_extract_usage() + { + IFC_ERR << STR("ifc extract usage:\n") + << STR("\tifc extract [--force] [-o ] --all\n") + << STR("\tifc extract [--force] [-o ] ") + << STR("( | --quote-header | --angle-header )...\n"); + } +} + +int ifc::tool::ArchiveCommand::run_with(const ifc::tool::Arguments& args) const +{ + ifc::tool::StringView output; + ifc::tool::StringView archive_name; + bool have_output = false; + std::vector positionals; + + for (std::size_t i = 0; i < args.size(); ++i) + { + const ifc::tool::StringView& arg = args[i]; + ifc::tool::StringView value; + OptionMatch match = take_value(args, i, STR("-o"), STR("--output"), value); + if (match == OptionMatch::Value) + { + output = value; + have_output = true; + continue; + } + if (match == OptionMatch::Missing) + { + IFC_ERR << STR("archive: missing filename after ") << arg << std::endl; + return 1; + } + match = take_value(args, i, STR(""), STR("--name"), value); + if (match == OptionMatch::Value) + { + archive_name = value; + continue; + } + if (match == OptionMatch::Missing) + { + IFC_ERR << STR("archive: missing name after ") << arg << std::endl; + return 1; + } + if (resemble_option(arg)) + { + IFC_ERR << STR("archive: invalid option ") << arg << std::endl; + return 1; + } + positionals.push_back(arg); + } + + std::vector inputs; + if (have_output) + { + inputs = std::move(positionals); + } + else + { + // Without an explicit -o, the first positional names the archive to create. + if (positionals.size() < 2) + { + print_archive_usage(); + return 1; + } + output = positionals.front(); + inputs.assign(positionals.begin() + 1, positionals.end()); + } + + if (output.empty()) + { + IFC_ERR << STR("archive: empty output filename") << std::endl; + return 1; + } + if (inputs.empty()) + { + IFC_ERR << STR("archive: no input IFC files specified") << std::endl; + return 1; + } + + ifc::fs::path output_path { output }; + if (not create_archive(output_path, inputs, archive_name)) + return 1; + return 0; +} + +int ifc::tool::ExtractCommand::run_with(const ifc::tool::Arguments& args) const +{ + ifc::tool::StringView output_dir; + bool extract_all = false; + ExistingFile existing = ExistingFile::Reject; + std::vector positionals; + std::vector headers; + + for (std::size_t i = 0; i < args.size(); ++i) + { + const ifc::tool::StringView& arg = args[i]; + if (arg == STR("--all")) + { + extract_all = true; + continue; + } + if (arg == STR("--force")) + { + existing = ExistingFile::Replace; + continue; + } + ifc::tool::StringView value; + OptionMatch match = take_value(args, i, STR("-o"), STR("--output-dir"), value); + if (match == OptionMatch::Value) + { + output_dir = value; + continue; + } + if (match == OptionMatch::Missing) + { + IFC_ERR << STR("extract: missing directory after ") << arg << std::endl; + return 1; + } + match = take_value(args, i, STR(""), STR("--quote-header"), value); + if (match == OptionMatch::Value) + { + std::u8string canonical = canonical_header_name(HeaderNameForm::Quote, value); + if (canonical.empty()) + { + IFC_ERR << STR("extract: invalid quote-form header name ") << value << std::endl; + return 1; + } + headers.push_back({ value, std::move(canonical), HeaderNameForm::Quote }); + continue; + } + if (match == OptionMatch::Missing) + { + IFC_ERR << STR("extract: missing header name after ") << arg << std::endl; + return 1; + } + match = take_value(args, i, STR(""), STR("--angle-header"), value); + if (match == OptionMatch::Value) + { + std::u8string canonical = canonical_header_name(HeaderNameForm::Angle, value); + if (canonical.empty()) + { + IFC_ERR << STR("extract: invalid angle-form header name ") << value << std::endl; + return 1; + } + headers.push_back({ value, std::move(canonical), HeaderNameForm::Angle }); + continue; + } + if (match == OptionMatch::Missing) + { + IFC_ERR << STR("extract: missing header name after ") << arg << std::endl; + return 1; + } + if (resemble_option(arg)) + { + IFC_ERR << STR("extract: invalid option ") << arg << std::endl; + return 1; + } + positionals.push_back(arg); + } + + if (positionals.empty()) + { + print_extract_usage(); + return 1; + } + const ifc::tool::StringView archive = positionals.front(); + const std::vector names(positionals.begin() + 1, positionals.end()); + + if (extract_all and (not names.empty() or not headers.empty())) + { + IFC_ERR << STR("extract: cannot combine --all with specific member selectors") << std::endl; + return 1; + } + if (not extract_all and names.empty() and headers.empty()) + { + IFC_ERR << STR("extract: specify --all or one or more members to extract") << std::endl; + return 1; + } + + const ifc::fs::path directory = output_dir.empty() ? ifc::fs::path { STR(".") } : ifc::fs::path { output_dir }; + + // Map the archive once; every read below -- header, table of contents, string table, and member + // content -- comes from these same hash-verified bytes, so there is no verify-then-read gap. + try + { + ifc::tool::InputFile image { ifc::fs::path { archive }.native() }; + ArchiveReader reader; + if (not open_archive(image.contents(), archive, reader)) + return 1; + SecureDirectory output { directory }; + + auto extract_one = [&](const ArchiveMember& member) -> bool + { + const ifc::tool::InputFile::View member_image = reader.member_image(member); + const ifc::fs::path relative = reader.relative_path(member); + const ifc::fs::path destination = relative.filename(); + + // Write to a unique temporary beside the target, then rename, so a failure never leaves a + // partial or half-written file in its place. + try + { + SecureDirectory parent { output, relative.parent_path() }; + TemporaryFile temp { parent, destination }; + temp.output().write(member_image); + const CommitFileResult committed = temp.commit_in(parent, destination, existing); + if (committed == CommitFileResult::Exists) + { + IFC_ERR << relative.native() << STR(": file already exists; use --force to replace it") + << std::endl; + return false; + } + } + catch (const UnsafeOutputDirectoryError&) + { + IFC_ERR << relative.native() << STR(": output path contains an unsafe directory") << std::endl; + return false; + } + catch (const TemporaryFileCreateError&) + { + IFC_ERR << relative.native() << STR(": couldn't create temporary output file") << std::endl; + return false; + } + catch (const NativeFileError&) + { + IFC_ERR << relative.native() << STR(": couldn't write extracted file") << std::endl; + return false; + } + catch (const TemporaryFileCommitError&) + { + IFC_ERR << relative.native() << STR(": couldn't finalize extracted file") << std::endl; + return false; + } + IFC_OUT << STR("extracted ") << (directory / relative).native() << std::endl; + return true; + }; + + int error_count = 0; + if (extract_all) + { + for (const ArchiveMember& member : reader.members) + if (not extract_one(member)) + ++error_count; + } + else + { + for (const ifc::tool::StringView& requested : names) + { + // A positional spelling selects named modules only; header-unit form is explicit. + const std::u8string want = to_utf8(requested); + const std::u8string_view want_view { want }; + const auto matches = reader.members_named(want_view); + bool found = false; + for (const ArchiveMember& member : matches) + { + if (named_module_sort(member.name.sort())) + { + found = true; + if (not extract_one(member)) + ++error_count; + } + } + if (not found) + { + IFC_ERR << requested << STR(": no named module with that name in the archive") << std::endl; + ++error_count; + } + } + for (const HeaderRequest& requested : headers) + { + const ArchiveMember* member = reader.member({ requested.canonical, ifc::UnitSort::Header }); + if (member == nullptr) + { + IFC_ERR << requested.spelling + << (requested.form == HeaderNameForm::Quote + ? STR(": no quote-form header unit with that name in the archive") + : STR(": no angle-form header unit with that name in the archive")) + << std::endl; + ++error_count; + continue; + } + if (not extract_one(*member)) + ++error_count; + } + } + return error_count; + } + catch (const ifc::tool::AccessError&) + { + IFC_ERR << archive << STR(": couldn't open file") << std::endl; + return 1; + } + catch (const OutputDirectoryError& error) + { + IFC_ERR << error.path.native() << STR(": couldn't create or secure the output directory") << std::endl; + return 1; + } + catch (const ifc::tool::RegularFileError&) + { + IFC_ERR << archive << STR(": not a regular file") << std::endl; + return 1; + } + catch (const ifc::tool::FileMappingError&) + { + IFC_ERR << archive << STR(": couldn't memory-map file") << std::endl; + return 1; + } +} diff --git a/src/tools/ifc-archive.hxx b/src/tools/ifc-archive.hxx new file mode 100644 index 0000000..aeec0c5 --- /dev/null +++ b/src/tools/ifc-archive.hxx @@ -0,0 +1,30 @@ +// Copyright Microsoft Corporation. +// SPDX-License-Identifier: Apache-2.0 WITH LLVM-exception + +#ifndef IFC_ARCHIVE_INCLUDED +#define IFC_ARCHIVE_INCLUDED + +#include "ifc/tooling.hxx" +#include "tool-support.hxx" // for STR in the constexpr subcommand names + +namespace ifc::tool { + // -- Subcommand creating an archive IFC that packages a set of non-archive IFC files, + // -- keyed by each member's UnitSort and canonical name. + struct ArchiveCommand final : Extension { + // -- A constexpr registry key lets the builtin table prove its ordering at compile time. + constexpr Name name() const final { return STR("archive"); } + // -- Keeps archive option validation and diagnostics behind the Extension boundary. + int run_with(const Arguments& args) const final; + }; + + // -- Subcommand extracting named modules and explicitly delimited header units from an archive + // -- IFC, writing each selected member back to its recorded filepath. + struct ExtractCommand final : Extension { + // -- A constexpr registry key lets the builtin table prove its ordering at compile time. + constexpr Name name() const final { return STR("extract"); } + // -- Keeps overwrite authority and extraction policy behind the Extension boundary. + int run_with(const Arguments& args) const final; + }; +} + +#endif diff --git a/src/tools/ifc.cxx b/src/tools/ifc.cxx index bed3739..242bf5e 100644 --- a/src/tools/ifc.cxx +++ b/src/tools/ifc.cxx @@ -1,40 +1,36 @@ // Copyright Microsoft Corporation. // SPDX-License-Identifier: Apache-2.0 WITH LLVM-exception -#include +#include +#include +#include #include -#include #include #include #include #ifdef WIN32 # include +#else +# include +# include #endif #include "ifc/file.hxx" #include "ifc/tooling.hxx" +#include "tool-support.hxx" +#include "ifc-archive.hxx" #ifdef WIN32 -# define STR(S) L ## S # define IFC_MAIN wmain -# define IFC_OUT std::wcout -# define IFC_ERR std::wcerr -#else -# define STR(S) S +#else # define IFC_MAIN main -# define IFC_OUT std::cout -# define IFC_ERR std::cerr #endif namespace { - using ProgramName = ifc::tool::StringView; - - // This predicate holds if the argument starts with a dash. - bool resemble_option(const ifc::tool::StringView& s) - { - return s.starts_with(STR("-")); - } + // The driver and its subcommands share the option-parsing and IFC-reading helpers declared + // in tool-support.hxx. + using namespace ifc::tool; // -- Print a brief message of how to invoke the ifc tool. void print_usage(const ifc::fs::path& prog) @@ -44,19 +40,11 @@ namespace { << name.native() << STR(" [options] \n"); } - // -- Check that the input file has a valid IFC file header signature. - bool validate_ifc_signature(std::ifstream& file) - { - constexpr auto sz = sizeof ifc::InterfaceSignature; - std::array sig { }; - if (not file.read(reinterpret_cast(sig.data()), sz)) - return false; - return std::memcmp(sig.data(), std::begin(ifc::InterfaceSignature), sz) == 0; - } - // -- Subcommand printing the Spec version from an IFC file. - struct VersionCommand : ifc::tool::Extension { - ifc::tool::Name name() const final { return STR("version"); } + struct VersionCommand final : ifc::tool::Extension { + // -- A constexpr registry key lets the builtin table prove its ordering at compile time. + constexpr ifc::tool::Name name() const final { return STR("version"); } + // -- Version inspection reads only the fixed prefix, avoiding whole-file address-space use. int run_with(const ifc::tool::Arguments& args) const final { int error_count = 0; @@ -64,28 +52,31 @@ namespace { { if (resemble_option(arg)) { - IFC_ERR << STR("invalid option ") << arg + IFC_ERR << STR("invalid option ") << arg << STR(" to ifc subcommand ") << name() << std::endl; ++error_count; continue; } - ifc::fs::path path{arg}; - std::ifstream file{path, std::ios_base::binary}; + ifc::fs::path path { arg }; + std::ifstream file { path, std::ios_base::binary }; if (not file) { IFC_ERR << arg << STR(": couldn't open file") << std::endl; ++error_count; continue; } - if (not validate_ifc_signature(file)) + // Only the signature and header are needed, so read just those bytes rather + // than mapping (and reserving address space for) the entire file. + ifc::Header header { }; + const HeaderRead status = read_ifc_header(file, header); + if (status == HeaderRead::NotIfc) { IFC_ERR << arg << STR(" is not an IFC file") << std::endl; ++error_count; continue; } - ifc::Header header {}; - if (not file.read(reinterpret_cast(&header), sizeof header)) + if (status == HeaderRead::Truncated) { IFC_ERR << arg << STR(" is truncated or corrupted") << std::endl; ++error_count; @@ -101,14 +92,21 @@ namespace { } }; + // -- Static command objects give the constexpr registry stable addresses without dynamic setup. constexpr VersionCommand version_cmd { }; + constexpr ifc::tool::ArchiveCommand archive_cmd { }; + constexpr ifc::tool::ExtractCommand extract_cmd { }; // -- List of all builtin subcommands, sorted by their name. constexpr const ifc::tool::Extension* builtin_extensions[] { + &archive_cmd, + &extract_cmd, &version_cmd, }; - static_assert(std::ranges::is_sorted(builtin_extensions, { }, &ifc::tool::Extension::name)); + static_assert(std::ranges::is_sorted(builtin_extensions, { }, + [](const ifc::tool::Extension* ext) { return ext->name(); })); + // -- Binary lookup relies on the compile-time ordering assertion above. const ifc::tool::Extension* builtin_operation(const ifc::tool::Name& cmd) { auto ext = std::ranges::lower_bound(builtin_extensions, cmd, { }, &ifc::tool::Extension::name); @@ -117,19 +115,172 @@ namespace { return nullptr; } - // Enclose the argument in double quotes. - ifc::tool::String quote(const ifc::tool::StringView& s) +#ifdef _WIN32 + // -- Quote `arg` for a Windows command line so that CommandLineToArgvW recovers it exactly. + ifc::tool::String quote_windows(ifc::tool::StringView arg) + { + if (not arg.empty() and arg.find_first_of(STR(" \t\n\v\"")) == ifc::tool::StringView::npos) + return ifc::tool::String { arg }; + ifc::tool::String result; + result.push_back(L'"'); + for (auto it = arg.begin();; ++it) + { + std::size_t backslashes = 0; + while (it != arg.end() and *it == L'\\') + { + ++it; + ++backslashes; + } + if (it == arg.end()) + { + result.append(backslashes * 2, L'\\'); + break; + } + if (*it == L'"') + { + result.append(backslashes * 2 + 1, L'\\'); + result.push_back(L'"'); + } + else + { + result.append(backslashes, L'\\'); + result.push_back(*it); + } + } + result.push_back(L'"'); + return result; + } + + // -- Find `program` with a ".exe" extension in the directories listed in `search_path` (a + // -- semicolon-separated list; a single directory is fine). Return the full path, or an empty + // -- string if it was not found there. + ifc::tool::String find_in_directories(const wchar_t* search_path, const ifc::tool::String& program) + { + std::wstring buffer(32 * 1024, L'\0'); + const DWORD n = SearchPathW(search_path, program.c_str(), L".exe", + static_cast(buffer.size()), buffer.data(), nullptr); + if (n == 0 or n >= buffer.size()) + return {}; + buffer.resize(n); + return ifc::tool::String { buffer }; + } + + // -- Resolve the external extension `program` (e.g. "ifc-foo") to a full executable path, + // -- searching first alongside this running tool and then the directories on PATH -- but never + // -- the current directory, so an executable planted in the working directory cannot be run in + // -- place of a real extension. Return an empty string if no such extension is found. + ifc::tool::String resolve_extension(const ifc::tool::String& program) { - static constexpr auto kwote_str = STR("\""); - ifc::tool::String r = kwote_str; - r += s; - r += kwote_str; - return r; + std::wstring module_path(32 * 1024, L'\0'); + const DWORD module_length = + GetModuleFileNameW(nullptr, module_path.data(), static_cast(module_path.size())); + if (module_length != 0 and module_length < module_path.size()) + { + module_path.resize(module_length); + const ifc::fs::path here = ifc::fs::path { module_path }.parent_path(); + ifc::tool::String found = find_in_directories(here.c_str(), program); + if (not found.empty()) + return found; + } + + const DWORD needed = GetEnvironmentVariableW(L"PATH", nullptr, 0); + if (needed != 0) + { + std::wstring path_value(needed, L'\0'); + const DWORD written = GetEnvironmentVariableW(L"PATH", path_value.data(), needed); + path_value.resize(written); + return find_in_directories(path_value.c_str(), program); + } + return {}; + } + + // -- Run the external extension `program` with `args` directly -- no shell, so command-line + // -- metacharacters in the arguments are inert. The executable is resolved on a trusted search + // -- path (never the current directory) and passed explicitly, so CreateProcessW performs no + // -- search of its own. Return the process exit code, or -1 if the extension could not be + // -- started. + int spawn_extension(const ifc::tool::String& program, const ifc::tool::Arguments& args) + { + const ifc::tool::String executable = resolve_extension(program); + if (executable.empty()) + return -1; + + ifc::tool::String command_line = quote_windows(program); + for (const auto& arg : args) + { + command_line.push_back(L' '); + command_line += quote_windows(arg); + } + STARTUPINFOW startup { }; + startup.cb = sizeof startup; + PROCESS_INFORMATION process { }; + if (not CreateProcessW(executable.c_str(), command_line.data(), nullptr, nullptr, FALSE, 0, nullptr, nullptr, + &startup, &process)) + return -1; + WaitForSingleObject(process.hProcess, INFINITE); + DWORD code = 0; + GetExitCodeProcess(process.hProcess, &code); + CloseHandle(process.hProcess); + CloseHandle(process.hThread); + return static_cast(code); + } +#else + // -- Run the external extension `program` with `args` directly -- no shell, so command-line + // -- metacharacters in the arguments are inert. Return the process exit code, or -1 if the + // -- extension could not be started. + int spawn_extension(const ifc::tool::String& program, const ifc::tool::Arguments& args) + { + std::vector storage; + storage.reserve(args.size()); + for (const auto& arg : args) + storage.emplace_back(arg); + std::vector argv; + argv.reserve(storage.size() + 2); + argv.push_back(const_cast(program.c_str())); + for (std::string& s : storage) + argv.push_back(s.data()); + argv.push_back(nullptr); + + const pid_t pid = fork(); + if (pid < 0) + return -1; + if (pid == 0) + { + execvp(program.c_str(), argv.data()); + _exit(127); // exec failed, e.g. the extension was not found on PATH + } + int status = 0; + if (waitpid(pid, &status, 0) < 0) + return -1; + if (not WIFEXITED(status) or WEXITSTATUS(status) == 127) + return -1; + return WEXITSTATUS(status); + } +#endif + + // -- Run a builtin subcommand, turning an out-of-memory or otherwise unexpected exception + // -- into a diagnostic that names the subcommand, and a non-zero exit. + int run_builtin(const ifc::tool::Extension& op, const ifc::tool::Name& cmd, const ifc::tool::Arguments& args) + try + { + return op.run_with(args); + } + catch (const std::bad_alloc&) + { + IFC_ERR << STR("ifc ") << cmd << STR(": out of memory") << std::endl; + return 1; + } + catch (...) + { + IFC_ERR << STR("ifc ") << cmd << STR(": unexpected internal error") << std::endl; + return 1; } } +// -- Centralizes final exception containment after builtin or external command dispatch. int IFC_MAIN(int argc, ifc::tool::NativeChar* argv[]) +try { // The `ifc` tool itself does not accept any option. int idx = 1; @@ -159,24 +310,26 @@ int IFC_MAIN(int argc, ifc::tool::NativeChar* argv[]) ifc::tool::Arguments args { argv + idx + 1, argv + argc }; if (auto op = builtin_operation(cmd)) - return op->run_with(args); + return run_builtin(*op, cmd, args); + // Otherwise dispatch to an external `ifc-` extension found on PATH. ifc::tool::String tool = STR("ifc-"); tool += cmd; - ifc::tool::String command = quote(tool); - for (auto& arg : args) + const int status = spawn_extension(tool, args); + if (status < 0) { - command += STR(" "); - command += quote(arg); - } - -#ifdef WIN32 - auto status = _wsystem(command.c_str()); -#else - auto status = std::system(command.c_str()); -#endif - if (status != 0) IFC_ERR << STR("ifc: no subcommand named '") << cmd << STR("'") << std::endl; + return 1; + } return status; } - +catch (const std::bad_alloc&) +{ + IFC_ERR << STR("ifc: out of memory") << std::endl; + return 1; +} +catch (...) +{ + IFC_ERR << STR("ifc: unexpected internal error") << std::endl; + return 1; +} diff --git a/src/tools/tool-support.cxx b/src/tools/tool-support.cxx new file mode 100644 index 0000000..776aa39 --- /dev/null +++ b/src/tools/tool-support.cxx @@ -0,0 +1,205 @@ +// Copyright Microsoft Corporation. +// SPDX-License-Identifier: Apache-2.0 WITH LLVM-exception + +#include +#include +#include +#include +#include + +#ifdef _WIN32 +# include +#endif + +#include "tool-support.hxx" + +namespace ifc::tool { + bool resemble_option(const ifc::tool::StringView& s) + { + return s.starts_with(STR("-")); + } + + std::u8string_view string_at(std::u8string_view table, std::uint32_t offset) + { + if (offset >= table.size()) + return {}; + const std::size_t stop = table.find(u8'\0', offset); + if (stop == std::u8string_view::npos) + return {}; + return table.substr(offset, stop - offset); + } + + HeaderRead read_ifc_header(std::ifstream& in, ifc::Header& header) + { + std::array signature { }; + if (not in.read(reinterpret_cast(signature.data()), signature.size()) + or std::memcmp(signature.data(), std::begin(ifc::InterfaceSignature), signature.size()) != 0) + return HeaderRead::NotIfc; + if (not in.read(reinterpret_cast(&header), sizeof header)) + return HeaderRead::Truncated; + return HeaderRead::Ok; + } + + OptionMatch take_value(const ifc::tool::Arguments& args, std::size_t& i, ifc::tool::StringView short_name, + ifc::tool::StringView long_name, ifc::tool::StringView& value) + { + const ifc::tool::StringView arg = args[i]; + if ((not short_name.empty() and arg == short_name) or arg == long_name) + { + if (i + 1 >= args.size()) + return OptionMatch::Missing; + value = args[++i]; + return OptionMatch::Value; + } + if (arg.size() > long_name.size() and arg.starts_with(long_name) and arg[long_name.size()] == STR("=")[0]) + { + value = arg.substr(long_name.size() + 1); + return OptionMatch::Value; + } + return OptionMatch::No; + } + + std::u8string to_utf8(ifc::tool::StringView s) + { +#ifdef _WIN32 + if (s.empty()) + return {}; + const int count = + WideCharToMultiByte(CP_UTF8, 0, s.data(), static_cast(s.size()), nullptr, 0, nullptr, nullptr); + std::u8string result(static_cast(count), u8'\0'); + WideCharToMultiByte(CP_UTF8, 0, s.data(), static_cast(s.size()), + reinterpret_cast(result.data()), count, nullptr, nullptr); + return result; +#else + return std::u8string(reinterpret_cast(s.data()), s.size()); +#endif + } + + bool valid_ifc_image(ifc::tool::InputFile::View bytes) + { + return bytes.size() >= sizeof ifc::InterfaceSignature + sizeof(ifc::Header) + and std::memcmp(bytes.data(), std::begin(ifc::InterfaceSignature), sizeof ifc::InterfaceSignature) == 0; + } + + const ifc::Header& ifc_header(ifc::tool::InputFile::View bytes) + { + return *reinterpret_cast(bytes.data() + sizeof ifc::InterfaceSignature); + } + + ifc::fs::path safe_relative(std::u8string_view stored) + try + { + const ifc::fs::path original { std::u8string { stored } }; + ifc::fs::path result; + for (const ifc::fs::path& component : original.relative_path()) + { + if (component.native() == STR("..")) + return {}; + if (component.native() == STR(".")) + continue; +#ifdef _WIN32 + const std::wstring& text = component.native(); + if (text.find(L':') != std::wstring::npos) // drive-relative path or NTFS data stream + return {}; + if (text.ends_with(L'.') or text.ends_with(L' ')) + return {}; + std::wstring base = text.substr(0, text.find(L'.')); + for (wchar_t& c : base) + if (c >= L'a' and c <= L'z') + c = static_cast(c - L'a' + L'A'); + static constexpr const wchar_t* devices[] = { L"CON", L"PRN", L"AUX", L"NUL", L"COM1", L"COM2", L"COM3", + L"COM4", L"COM5", L"COM6", L"COM7", L"COM8", L"COM9", L"LPT1", L"LPT2", L"LPT3", L"LPT4", L"LPT5", + L"LPT6", L"LPT7", L"LPT8", L"LPT9" }; + for (const wchar_t* device : devices) + if (base == device) + return {}; +#endif + result /= component; + } + return result; + } + catch (const ifc::fs::filesystem_error&) + { + return {}; + } + + StringTableBuilder::Handle StringTableBuilder::intern(std::u8string_view s) + { + if (const auto it = index_map.find(s); it != index_map.end()) + return it->second; + if (strings.size() > std::numeric_limits::max()) + throw StringTableOverflow { strings.size() }; + const Handle handle = static_cast(strings.size()); + const std::u8string& stored = strings.emplace_back(s); + index_map.emplace(std::u8string_view { stored }, handle); + return handle; + } + + void StringTableBuilder::build() + { + table.clear(); + table.push_back(std::byte { 0 }); // offset 0: the empty string, also the null TextOffset + offsets.assign(strings.size(), ifc::TextOffset { 0 }); + + // Order the non-empty strings by their reversed bytes, so that a string which is a suffix + // of another becomes adjacent to it; empty strings already map to offset 0. + std::vector order; + order.reserve(strings.size()); + for (std::uint32_t i = 0; i < strings.size(); ++i) + if (not strings[i].empty()) + order.push_back(i); + std::ranges::sort(order, [this](std::uint32_t a, std::uint32_t b) + { + const std::u8string& x = strings[a]; + const std::u8string& y = strings[b]; + auto xi = x.rbegin(); + auto yi = y.rbegin(); + for (; xi != x.rend() and yi != y.rend(); ++xi, ++yi) + if (*xi != *yi) + return *xi < *yi; + return x.size() < y.size(); + }); + + // Walk from the longest suffix downwards; a string that is a suffix of the previously + // placed string is overlaid on it, otherwise it is appended with its own terminator. + const std::u8string* previous = nullptr; + std::uint32_t previous_offset = 0; + for (std::size_t k = order.size(); k-- != 0; ) + { + const std::uint32_t i = order[k]; + const std::u8string& s = strings[i]; + if (previous != nullptr and s.size() <= previous->size() + and previous->compare(previous->size() - s.size(), s.size(), s) == 0) + { + offsets[i] = ifc::TextOffset { previous_offset + + static_cast(previous->size() - s.size()) }; + } + else + { + if (table.size() > std::numeric_limits::max()) + throw StringTableOverflow { table.size() }; + offsets[i] = ifc::TextOffset { static_cast(table.size()) }; + for (char8_t code_unit : s) + table.push_back(static_cast(code_unit)); + table.push_back(std::byte { 0 }); + } + previous = &s; + previous_offset = std::to_underlying(offsets[i]); + } + } + + ifc::TextOffset StringTableBuilder::offset(Handle h) const + { + // build() must have run and assigned this handle an offset; a handle interned after the + // last build(), or any query before the first, falls outside `offsets` -- a usage error + // we reject in every build mode (an assert would vanish under NDEBUG). + if (std::to_underlying(h) >= offsets.size()) + throw UnresolvedStringHandle { h }; + return offsets[std::to_underlying(h)]; + } + + gsl::span StringTableBuilder::bytes() const + { + return table; + } +} diff --git a/src/tools/tool-support.hxx b/src/tools/tool-support.hxx new file mode 100644 index 0000000..fd2e077 --- /dev/null +++ b/src/tools/tool-support.hxx @@ -0,0 +1,139 @@ +// Copyright Microsoft Corporation. +// SPDX-License-Identifier: Apache-2.0 WITH LLVM-exception + +#ifndef IFC_TOOL_SUPPORT_INCLUDED +#define IFC_TOOL_SUPPORT_INCLUDED + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +#include "ifc/file.hxx" +#include "ifc/tooling.hxx" + +// -- Convenience macros bridging the wide (Windows) and narrow (POSIX) native-string worlds +// -- shared by the ifc driver and its subcommands. STR() adorns a literal with the native +// -- character prefix; IFC_OUT/IFC_ERR select the native-width standard streams. +#ifdef _WIN32 +# define STR(S) L ## S +# define IFC_OUT std::wcout +# define IFC_ERR std::wcerr +#else +# define STR(S) S +# define IFC_OUT std::cout +# define IFC_ERR std::cerr +#endif + +// Shared support routines for building ifc subcommands: option parsing, IFC header/string-table +// reading, file-region copying, and extraction-path hardening. +namespace ifc::tool { + // -- Return true if `s` looks like an option (it starts with a dash). + bool resemble_option(const ifc::tool::StringView& s); + + // -- Locate the NUL-terminated UTF-8 string at `offset` within the string table `table`, + // -- returning an empty view if the offset is out of range. + std::u8string_view string_at(std::u8string_view table, std::uint32_t offset); + + // -- Outcome of reading an IFC file signature and fixed header. + enum class HeaderRead { + Ok, + NotIfc, // Missing or wrong signature. + Truncated, // Signature present but the header is incomplete. + }; + + // -- Read the 4-byte signature and fixed header from `in`, positioned at the start. + HeaderRead read_ifc_header(std::ifstream& in, ifc::Header& header); + + // -- Result of matching a value-taking command-line option. + enum class OptionMatch { + No, // `args[i]` is not this option. + Value, // Matched; `value` was set. + Missing, // Matched the spelling, but no value followed. + }; + + // -- Match `args[i]` as a value-taking option with spellings `short_name` (may be empty) or + // -- `long_name`, in the `spelling value` form (consuming the next argument by advancing `i`) + // -- or the `long_name=value` form. Set `value` on a match. + OptionMatch take_value(const ifc::tool::Arguments& args, std::size_t& i, ifc::tool::StringView short_name, + ifc::tool::StringView long_name, ifc::tool::StringView& value); + + // -- Convert a native command-line string to UTF-8 without interpreting it as a pathname. + std::u8string to_utf8(ifc::tool::StringView s); + + // -- Return true if `bytes` begins with a valid IFC file header (signature plus room for + // -- the fixed header). + bool valid_ifc_image(ifc::tool::InputFile::View bytes); + + // -- Gives validated mapped-image users one definition of the fixed header location. + // -- `bytes` must satisfy valid_ifc_image(). + const ifc::Header& ifc_header(ifc::tool::InputFile::View bytes); + + // -- Reduce a stored member filepath to a safe relative path under an extraction directory: + // -- drop any root, skip '.', and reject '..' (and, on Windows, reserved device names and + // -- alternate-data-stream ':' components) so a crafted archive cannot escape. Return an + // -- empty path if the filepath is unusable. + ifc::fs::path safe_relative(std::u8string_view stored); + + // -- Exception thrown when a string table cannot be represented within the 32-bit IFC limits: + // -- its bytes would overflow a TextOffset (raised by build()), or it holds more distinct + // -- strings than a 32-bit handle can address (raised by intern()). + struct StringTableOverflow { + std::uint64_t size; // The offending size: table bytes, or the count of interned strings. + }; + + // -- Accumulates strings and emits a single string table with exact-duplicate dedup and + // -- maximal suffix sharing (a string that is a suffix of another is overlaid on it). Offsets + // -- are assigned only at build(), so one table can back several callers -- the archive writer + // -- now, and a future `link` that fuses every member's string table into one -- with references + // -- resolved after. + struct StringTableBuilder { + // -- Opaque reference to an interned string, resolved to a TextOffset by build(). + enum class Handle : std::uint32_t {}; + + StringTableBuilder() = default; + // -- The dedup map keys are views into `strings`, so a copy would alias another builder's + // -- storage; the builder is therefore non-copyable (nothing needs to copy it). + StringTableBuilder(const StringTableBuilder&) = delete; + StringTableBuilder& operator=(const StringTableBuilder&) = delete; + + // -- Register `s` and return a handle for it; equal bytes yield the same handle. + Handle intern(std::u8string_view s); + + // -- Build the merged table, assigning every interned string a final offset. + void build(); + + // -- Offset of the string denoted by `h`, valid only after build(). Throws + // -- UnresolvedStringHandle if `h` has no assigned offset (queried before build(), or + // -- interned since the last build()). + ifc::TextOffset offset(Handle h) const; + + // -- The built table; its first byte is the NUL at offset 0. Valid after build(). + gsl::span bytes() const; + + private: + // `strings` owns each unique interned string exactly once and is indexed by handle; the + // keys of `index_map` are views into those elements (std::deque keeps element addresses + // stable across growth), so a lookup needs no allocation and the bytes are never stored + // twice. Because the map aliases `strings`, the builder is non-copyable. + std::deque strings; + std::unordered_map index_map; + std::vector offsets; // Each string's final offset, assigned by build(). + std::vector table; // The built table bytes; valid after build(). + }; + + // -- Exception thrown by StringTableBuilder::offset() when the handle has no resolved offset -- + // -- queried before build(), or interned since the last build(). Enforced in every build mode. + struct UnresolvedStringHandle { + StringTableBuilder::Handle handle; // The handle that build() has not resolved to an offset. + }; +} + +#endif diff --git a/test/CMakeLists.txt b/test/CMakeLists.txt index d04ab67..8e3cae1 100644 --- a/test/CMakeLists.txt +++ b/test/CMakeLists.txt @@ -50,3 +50,30 @@ add_test(NAME ifc-test COMMAND ifc-test) if (WIN32) add_test(NAME ifc-basic COMMAND ifc-basic) endif() + +# End-to-end test for the `ifc archive` / `ifc extract` subcommands. The member IFCs are +# produced here with MSVC (GCC and Clang cannot emit IFCs), so -- like ifc-basic -- this test is +# Windows-only. The members are built now; at test time only the ifc tool and cmake run, so no +# MSVC environment is needed then. +if (WIN32 AND TARGET ifc) + set(archive_work "${CMAKE_CURRENT_BINARY_DIR}/archive-test") + file(MAKE_DIRECTORY "${archive_work}") + add_custom_command( + OUTPUT "${archive_work}/m.ifc" + COMMAND ${CMAKE_CXX_COMPILER} /nologo /std:c++20 /ifcOutputm.ifc /c "${CMAKE_CURRENT_SOURCE_DIR}/m.ixx" + DEPENDS "${CMAKE_CURRENT_SOURCE_DIR}/m.ixx" + WORKING_DIRECTORY "${archive_work}") + add_custom_command( + OUTPUT "${archive_work}/n.ifc" + COMMAND ${CMAKE_CXX_COMPILER} /nologo /std:c++20 /ifcOutputn.ifc /c "${CMAKE_CURRENT_SOURCE_DIR}/n.ixx" + DEPENDS "${CMAKE_CURRENT_SOURCE_DIR}/n.ixx" + WORKING_DIRECTORY "${archive_work}") + add_custom_target(archive-test-ifcs ALL DEPENDS "${archive_work}/m.ifc" "${archive_work}/n.ifc") + + add_test( + NAME ifc-archive + COMMAND ${CMAKE_COMMAND} + "-DIFC_TOOL=$" + "-DWORK=${archive_work}" + -P "${CMAKE_CURRENT_SOURCE_DIR}/archive-roundtrip.cmake") +endif() diff --git a/test/archive-fixture.ps1 b/test/archive-fixture.ps1 new file mode 100644 index 0000000..98d67cb --- /dev/null +++ b/test/archive-fixture.ps1 @@ -0,0 +1,147 @@ +# Copyright Microsoft Corporation. +# SPDX-License-Identifier: Apache-2.0 WITH LLVM-exception + +param( + [Parameter(Mandatory = $true)] + [ValidateSet('quote-header-unit', 'angle-header-unit', 'tamper-content', 'tamper-hash', 'unsorted-toc', + 'bad-path-offset', 'overlap-extents', 'junction')] + [string]$Mode, + + [Parameter(Mandatory = $true)] + [string]$InputFile, + + [Parameter(Mandatory = $true)] + [string]$OutputFile +) + +$ErrorActionPreference = 'Stop' + +# IFC header and ArchiveMember offsets used by deliberate fixture mutation. +$contentHashOffset = 4 +$hashedContentsOffset = 36 +$stringTableFieldOffset = 44 +$stringTableSizeFieldOffset = 48 +$unitIndexOffset = 52 +$tocFieldOffset = 64 +$memberCountFieldOffset = 68 +$archiveMemberSize = 16 +$memberPathFieldOffset = 4 +$memberPayloadFieldOffset = 8 + +if ($Mode -eq 'junction') +{ + New-Item -ItemType Junction -Path $OutputFile -Target $InputFile | Out-Null + exit 0 +} + +$bytes = [IO.File]::ReadAllBytes($InputFile) + +# Recompute the outer IFC digest after deliberately changing authenticated metadata. +function Set-ContentHash +{ + param([byte[]]$Image) + + $sha = [Security.Cryptography.SHA256]::Create() + try + { + $digest = $sha.ComputeHash($Image, $hashedContentsOffset, $Image.Length - $hashedContentsOffset) + [Array]::Copy($digest, 0, $Image, $contentHashOffset, $digest.Length) + } + finally + { + $sha.Dispose() + } +} + +# Give the fixture a realistic delimited header-unit canonical name while preserving its layout. +function Set-HeaderUnitName +{ + param( + [byte[]]$Image, + [ValidateSet('quote', 'angle')] + [string]$Form + ) + + $unit = [BitConverter]::ToUInt32($Image, $unitIndexOffset) + $nameOffset = $unit -shr 3 + $tableOffset = [BitConverter]::ToUInt32($Image, $stringTableFieldOffset) + $tableSize = [BitConverter]::ToUInt32($Image, $stringTableSizeFieldOffset) + $start = $tableOffset + $nameOffset + $end = $start + while ($end -lt $tableOffset + $tableSize -and $Image[$end] -ne 0) + { + ++$end + } + if ($end -eq $tableOffset + $tableSize) + { + throw 'member canonical name is not terminated' + } + $name = [Text.Encoding]::UTF8.GetString($Image, $start, $end - $start) + $canonical = if ($Form -eq 'quote') { '"' + $name + '"' } else { '<' + $name + '>' } + $encoded = [Text.Encoding]::UTF8.GetBytes($canonical) + if ($start + $encoded.Length -ge $tableOffset + $tableSize) + { + throw 'string table has no room for a delimited fixture name' + } + [Array]::Copy($encoded, 0, $Image, $start, $encoded.Length) + $Image[$start + $encoded.Length] = 0 + + $unit = ($unit -band 0xfffffff8) -bor 3 + [BitConverter]::GetBytes([uint32]$unit).CopyTo($Image, $unitIndexOffset) + Set-ContentHash $Image +} + +switch ($Mode) +{ + 'quote-header-unit' + { + Set-HeaderUnitName $bytes quote + } + 'angle-header-unit' + { + Set-HeaderUnitName $bytes angle + } + 'tamper-content' + { + $bytes[$bytes.Length - 1] = $bytes[$bytes.Length - 1] -bxor 1 + } + 'tamper-hash' + { + $bytes[$contentHashOffset] = $bytes[$contentHashOffset] -bxor 1 + } + 'unsorted-toc' + { + $toc = [BitConverter]::ToUInt32($bytes, $tocFieldOffset) + $count = [BitConverter]::ToUInt32($bytes, $memberCountFieldOffset) + if ($count -lt 2) + { + throw 'unsorted-toc requires at least two archive members' + } + $first = [byte[]]::new($archiveMemberSize) + [Array]::Copy($bytes, $toc, $first, 0, $archiveMemberSize) + [Array]::Copy($bytes, $toc + $archiveMemberSize, $bytes, $toc, $archiveMemberSize) + [Array]::Copy($first, 0, $bytes, $toc + $archiveMemberSize, $archiveMemberSize) + Set-ContentHash $bytes + } + 'bad-path-offset' + { + $toc = [BitConverter]::ToUInt32($bytes, $tocFieldOffset) + [BitConverter]::GetBytes([uint32]::MaxValue).CopyTo($bytes, $toc + $memberPathFieldOffset) + Set-ContentHash $bytes + } + 'overlap-extents' + { + $toc = [BitConverter]::ToUInt32($bytes, $tocFieldOffset) + $count = [BitConverter]::ToUInt32($bytes, $memberCountFieldOffset) + if ($count -lt 2) + { + throw 'overlap-extents requires at least two archive members' + } + # ArchiveMember::offset is its third 32-bit field; make member 2 start at member 1. + [Array]::Copy($bytes, $toc + $memberPayloadFieldOffset, $bytes, + $toc + $archiveMemberSize + $memberPayloadFieldOffset, 4) + Set-ContentHash $bytes + } +} + +[IO.File]::WriteAllBytes($OutputFile, $bytes) diff --git a/test/archive-roundtrip.cmake b/test/archive-roundtrip.cmake new file mode 100644 index 0000000..8ad443f --- /dev/null +++ b/test/archive-roundtrip.cmake @@ -0,0 +1,160 @@ +# Copyright Microsoft Corporation. +# SPDX-License-Identifier: Apache-2.0 WITH LLVM-exception + +# End-to-end round-trip test for the `ifc archive` and `ifc extract` subcommands. +# Invoked as: +# cmake -DIFC_TOOL= -DWORK= -P archive-roundtrip.cmake +# Only the ifc tool and cmake are run here, so no MSVC environment is required at test time. +# The first unmet expectation aborts with a non-zero exit via message(FATAL_ERROR). + +if(NOT IFC_TOOL OR NOT WORK) + message(FATAL_ERROR "archive-roundtrip.cmake requires -DIFC_TOOL and -DWORK") +endif() + +set(fixture "${CMAKE_CURRENT_LIST_DIR}/archive-fixture.ps1") + +# -- A separate working directory is needed to exercise relative-path normalization faithfully. +function(run_ok_in directory) + execute_process(COMMAND ${ARGN} WORKING_DIRECTORY "${directory}" RESULT_VARIABLE rc) + if(NOT rc EQUAL 0) + message(FATAL_ERROR "expected success but got exit ${rc}: ${ARGN}") + endif() +endfunction() + +# -- Most commands run in the fixture directory; keep that policy out of individual test cases. +function(run_ok) + run_ok_in("${WORK}" ${ARGN}) +endfunction() + +# -- Rejected relative-path cases use their own working directory without weakening diagnostics. +function(run_fail_in directory) + execute_process(COMMAND ${ARGN} WORKING_DIRECTORY "${directory}" RESULT_VARIABLE rc + OUTPUT_QUIET ERROR_QUIET) + if(rc EQUAL 0) + message(FATAL_ERROR "expected failure but the command succeeded: ${ARGN}") + endif() +endfunction() + +# -- Most rejected commands run in the fixture directory and suppress their expected diagnostics. +function(run_fail) + run_fail_in("${WORK}" ${ARGN}) +endfunction() + +# -- Binary fixture mutations share one checked PowerShell invocation contract. +function(make_fixture mode input output) + run_ok(powershell -NoProfile -ExecutionPolicy Bypass -File "${fixture}" + -Mode "${mode}" -InputFile "${input}" -OutputFile "${output}") +endfunction() + +# Start from a clean slate, keeping the build-produced member IFCs (m.ifc, n.ifc). +file(REMOVE_RECURSE + "${WORK}/out" "${WORK}/named" "${WORK}/pkg.ifc" + "${WORK}/nested.ifc" "${WORK}/coll.ifc" "${WORK}/mcopy.ifc" + "${WORK}/quote.ifc" "${WORK}/angle.ifc" "${WORK}/sorts.ifc" "${WORK}/sorts-out" + "${WORK}/module-only" "${WORK}/quote-only" "${WORK}/angle-only" + "${WORK}/tampered-content.ifc" "${WORK}/tampered-hash.ifc" + "${WORK}/unsorted.ifc" "${WORK}/bad-path.ifc" "${WORK}/overlap.ifc" "${WORK}/invalid-out" + "${WORK}/path-parent" "${WORK}/path-work" "${WORK}/path.ifc" + "${WORK}/junction-source" "${WORK}/junction-out" "${WORK}/junction-outside" + "${WORK}/junction.ifc") + +# Archive two members, keyed by UnitSort plus canonical name, recording an archive name. +run_ok("${IFC_TOOL}" archive -o pkg.ifc --name testpkg m.ifc n.ifc) + +# `version` accepts the archive. +run_ok("${IFC_TOOL}" version pkg.ifc) + +# Extract everything and confirm each member is byte-identical to its original. +run_ok("${IFC_TOOL}" extract -o out pkg.ifc --all) +run_ok("${CMAKE_COMMAND}" -E compare_files "${WORK}/m.ifc" "${WORK}/out/m.ifc") +run_ok("${CMAKE_COMMAND}" -E compare_files "${WORK}/n.ifc" "${WORK}/out/n.ifc") + +# Extract a single member by canonical name; the other member must not be written. +run_ok("${IFC_TOOL}" extract -o named pkg.ifc m) +run_ok("${CMAKE_COMMAND}" -E compare_files "${WORK}/m.ifc" "${WORK}/named/m.ifc") +if(EXISTS "${WORK}/named/n.ifc") + message(FATAL_ERROR "selective extract wrote an unrequested member") +endif() + +# Existing destinations are preserved unless replacement authority is explicit. +file(WRITE "${WORK}/named/m.ifc" "sentinel") +run_fail("${IFC_TOOL}" extract -o named pkg.ifc m) +file(READ "${WORK}/named/m.ifc" preserved) +if(NOT preserved STREQUAL "sentinel") + message(FATAL_ERROR "extract modified an existing file without --force") +endif() +run_ok("${IFC_TOOL}" extract --force -o named pkg.ifc m) +run_ok("${CMAKE_COMMAND}" -E compare_files "${WORK}/m.ifc" "${WORK}/named/m.ifc") + +# Negative cases: unknown name, nesting an archive, and duplicate member identities. +run_fail("${IFC_TOOL}" extract -o bad pkg.ifc nosuchname) +run_fail("${IFC_TOOL}" archive -o nested.ifc pkg.ifc) +run_ok("${CMAKE_COMMAND}" -E copy m.ifc mcopy.ifc) +run_fail("${IFC_TOOL}" archive -o coll.ifc m.ifc mcopy.ifc) + +# Header-unit delimiter form is part of its canonical name and must survive CLI selection. +make_fixture(quote-header-unit "${WORK}/m.ifc" "${WORK}/quote.ifc") +make_fixture(angle-header-unit "${WORK}/m.ifc" "${WORK}/angle.ifc") +run_ok("${IFC_TOOL}" archive -o sorts.ifc m.ifc quote.ifc angle.ifc) +run_ok("${IFC_TOOL}" extract -o sorts-out sorts.ifc --all) +run_ok("${CMAKE_COMMAND}" -E compare_files "${WORK}/m.ifc" "${WORK}/sorts-out/m.ifc") +run_ok("${CMAKE_COMMAND}" -E compare_files "${WORK}/quote.ifc" "${WORK}/sorts-out/quote.ifc") +run_ok("${CMAKE_COMMAND}" -E compare_files "${WORK}/angle.ifc" "${WORK}/sorts-out/angle.ifc") + +run_ok("${IFC_TOOL}" extract -o module-only sorts.ifc m) +run_ok("${CMAKE_COMMAND}" -E compare_files "${WORK}/m.ifc" "${WORK}/module-only/m.ifc") +if(EXISTS "${WORK}/module-only/quote.ifc" OR EXISTS "${WORK}/module-only/angle.ifc") + message(FATAL_ERROR "module selector extracted a header unit") +endif() + +run_ok("${IFC_TOOL}" extract -o quote-only sorts.ifc --quote-header m) +run_ok("${CMAKE_COMMAND}" -E compare_files "${WORK}/quote.ifc" "${WORK}/quote-only/quote.ifc") +if(EXISTS "${WORK}/quote-only/m.ifc" OR EXISTS "${WORK}/quote-only/angle.ifc") + message(FATAL_ERROR "quote-header selector extracted another member form") +endif() + +run_ok("${IFC_TOOL}" extract -o angle-only sorts.ifc --angle-header=m) +run_ok("${CMAKE_COMMAND}" -E compare_files "${WORK}/angle.ifc" "${WORK}/angle-only/angle.ifc") +if(EXISTS "${WORK}/angle-only/m.ifc" OR EXISTS "${WORK}/angle-only/quote.ifc") + message(FATAL_ERROR "angle-header selector extracted another member form") +endif() + +run_fail("${IFC_TOOL}" extract -o bad sorts.ifc --quote-header absent) +run_fail("${IFC_TOOL}" extract -o bad sorts.ifc --all --angle-header m) +run_fail("${IFC_TOOL}" extract -o bad sorts.ifc --quote-header) +run_fail("${IFC_TOOL}" extract -o bad sorts.ifc "\"m\"") +run_fail("${IFC_TOOL}" extract -o bad sorts.ifc "") + +# Integrity failures and authenticated-but-malformed metadata are distinct rejection paths. +make_fixture(tamper-content "${WORK}/pkg.ifc" "${WORK}/tampered-content.ifc") +make_fixture(tamper-hash "${WORK}/pkg.ifc" "${WORK}/tampered-hash.ifc") +make_fixture(unsorted-toc "${WORK}/pkg.ifc" "${WORK}/unsorted.ifc") +make_fixture(bad-path-offset "${WORK}/pkg.ifc" "${WORK}/bad-path.ifc") +make_fixture(overlap-extents "${WORK}/pkg.ifc" "${WORK}/overlap.ifc") +run_fail("${IFC_TOOL}" extract -o invalid-out tampered-content.ifc --all) +run_fail("${IFC_TOOL}" extract -o invalid-out tampered-hash.ifc --all) +run_fail("${IFC_TOOL}" extract -o invalid-out unsorted.ifc --all) +run_fail("${IFC_TOOL}" extract -o invalid-out bad-path.ifc --all) +run_fail("${IFC_TOOL}" extract -o invalid-out overlap.ifc --all) + +# Creation rejects a filepath that extraction cannot reproduce beneath its output root. +file(MAKE_DIRECTORY "${WORK}/path-parent" "${WORK}/path-work") +run_ok("${CMAKE_COMMAND}" -E copy "${WORK}/m.ifc" "${WORK}/path-parent/m.ifc") +run_fail_in("${WORK}/path-work" "${IFC_TOOL}" archive -o "${WORK}/path.ifc" ../path-parent/m.ifc) + +# A pre-existing junction in an archive-controlled component cannot redirect extraction. +file(MAKE_DIRECTORY "${WORK}/junction-source/nested" "${WORK}/junction-out" "${WORK}/junction-outside") +run_ok("${CMAKE_COMMAND}" -E copy "${WORK}/m.ifc" "${WORK}/junction-source/nested/m.ifc") +run_ok_in("${WORK}/junction-source" "${IFC_TOOL}" archive -o "${WORK}/junction.ifc" nested/m.ifc) +make_fixture(junction "${WORK}/junction-outside" "${WORK}/junction-out/nested") +run_fail("${IFC_TOOL}" extract -o junction-out junction.ifc --all) +if(EXISTS "${WORK}/junction-outside/m.ifc") + message(FATAL_ERROR "extract followed a junction outside the output root") +endif() + +file(GLOB_RECURSE temporary_files "${WORK}/*.tmp") +if(temporary_files) + message(FATAL_ERROR "archive operations left temporary files behind: ${temporary_files}") +endif() + +message(STATUS "ifc archive/extract round-trip: all checks passed") diff --git a/test/basic.cxx b/test/basic.cxx index fb484de..3587fd6 100644 --- a/test/basic.cxx +++ b/test/basic.cxx @@ -308,8 +308,9 @@ TEST_CASE("IFC spec - Test header") InputIfc ifc; Reader reader = create_ifc_reader(IFC_FILE, &buf, &ifc); - // The currently documented version of the IFC that the MSVC compiler will emit is 0.44. - constexpr auto expected_version = FormatVersion{ Version{ 0 }, Version{ 44 } }; + // The IFC file-format version the toolset emits is tracked by CurrentFormatVersion + // (see ifc/version.hxx); compare against it so this check cannot fall out of date. + constexpr auto expected_version = CurrentFormatVersion; CHECK_MESSAGE(reader.ifc.header()->version == expected_version, "minor/major"); CHECK_MESSAGE(reader.ifc.header()->abi == Abi{}, "abi - not currently set in MSVC"); // arch - since we compile this file in multiple modes, let's just ensure that 'arch' matches one of the known types. diff --git a/test/n.ixx b/test/n.ixx new file mode 100644 index 0000000..4c1409b --- /dev/null +++ b/test/n.ixx @@ -0,0 +1,8 @@ +// Copyright Microsoft Corporation. +// SPDX-License-Identifier: Apache-2.0 WITH LLVM-exception + +// A second minimal module interface, used only as an extra member (with the distinct canonical +// name "n") in the ifc-archive round-trip test. +export module n; + +export int n_answer();