#include "libarchive.h" #include #include #include #include #include #include #include #include #include #include #include #include namespace fs = std::filesystem; namespace { constexpr std::size_t kArchiveBlockSize = 64 * 1024; [[noreturn]] void throw_invalid_archive(const std::string &reason) { throw Invalid_Archive_Error("invalid upgrade archive: " + reason); } std::string archive_error_text(archive *a, const std::string &stage) { const char *err = archive_error_string(a); if (err == nullptr || *err == '\0') { return stage; } return stage + ": " + err; } void require_archive_ok(archive *a, int code, const std::string &stage) { if (code != ARCHIVE_OK) { throw std::runtime_error(archive_error_text(a, stage)); } } std::string path_to_utf8(const fs::path &path) { const auto u8 = path.generic_u8string(); return std::string(u8.begin(), u8.end()); } bool path_is_within(const fs::path &base, const fs::path &target) { const fs::path canonical_base = fs::weakly_canonical(base); const fs::path canonical_target = fs::weakly_canonical(target); auto base_it = canonical_base.begin(); auto target_it = canonical_target.begin(); for (; base_it != canonical_base.end(); ++base_it, ++target_it) { if (target_it == canonical_target.end() || *base_it != *target_it) { return false; } } return true; } std::string normalize_archive_path(const std::string &raw, bool allow_empty = false) { if (raw.empty()) { if (allow_empty) return {}; throw_invalid_archive("empty entry path"); } if (raw.front() == '/' || raw.find('\\') != std::string::npos || raw.find(':') != std::string::npos) { throw_invalid_archive("unsafe entry path: " + raw); } std::string normalized; std::size_t start = 0; while (start < raw.size()) { const std::size_t slash = raw.find('/', start); const std::size_t end = slash == std::string::npos ? raw.size() : slash; const std::string component = raw.substr(start, end - start); if (component.empty() || component == "." || component == "..") { throw_invalid_archive("unsafe entry path: " + raw); } if (!normalized.empty()) normalized.push_back('/'); normalized += component; if (slash == std::string::npos) break; start = slash + 1; if (start == raw.size()) break; } return normalized; } std::string entry_path(archive_entry *entry) { const char *name = archive_entry_pathname_utf8(entry); if (name == nullptr) { name = archive_entry_pathname(entry); } if (name == nullptr) { throw_invalid_archive("entry path is unavailable"); } return normalize_archive_path(name); } bool is_directory_entry(archive_entry *entry) { return archive_entry_filetype(entry) == AE_IFDIR; } bool is_regular_entry(archive_entry *entry) { const auto type = archive_entry_filetype(entry); return type == AE_IFREG || type == 0; } void reject_unsupported_entry_type(archive_entry *entry, const std::string &path) { const auto type = archive_entry_filetype(entry); if (type == AE_IFLNK) { throw_invalid_archive("symbolic link is not allowed: " + path); } if (type != AE_IFDIR && type != AE_IFREG && type != 0) { throw_invalid_archive("special file is not allowed: " + path); } } void setup_archive_reader(archive *a) { require_archive_ok(a, archive_read_support_filter_all(a), "archive_read_support_filter_all failed"); require_archive_ok(a, archive_read_support_format_all(a), "archive_read_support_format_all failed"); } void consume_entry_data_for_validation(archive *a, const std::string &path, const Archive_Layout_Rules &rules, std::uint64_t &total_uncompressed_size) { std::uint64_t entry_size = 0; const void *block = nullptr; std::size_t block_size = 0; la_int64_t offset = 0; while (true) { const int result = archive_read_data_block(a, &block, &block_size, &offset); if (result == ARCHIVE_EOF) { break; } if (result != ARCHIVE_OK) { throw_invalid_archive( archive_error_text(a, "cannot read entry: " + path)); } if (block_size > rules.max_entry_uncompressed_size || entry_size > rules.max_entry_uncompressed_size - block_size) { throw_invalid_archive("entry size exceeds limit: " + path); } if (block_size > rules.max_total_uncompressed_size || total_uncompressed_size > rules.max_total_uncompressed_size - block_size) { throw_invalid_archive("uncompressed size exceeds limit"); } entry_size += block_size; total_uncompressed_size += block_size; } } void consume_entry_data_for_validation(archive *a, archive_entry *entry, const std::string &path, const Archive_Layout_Rules &rules, std::uint64_t &total_uncompressed_size) { if (archive_entry_size_is_set(entry)) { const auto expected_size = archive_entry_size(entry); if (expected_size < 0) { throw_invalid_archive("negative entry size: " + path); } const auto size = static_cast(expected_size); if (size > rules.max_entry_uncompressed_size || size > rules.max_total_uncompressed_size) { throw_invalid_archive("entry size exceeds limit: " + path); } } consume_entry_data_for_validation(a, path, rules, total_uncompressed_size); } std::unordered_set make_allowed_entries(const std::vector &required, const std::vector &optional) { std::unordered_set result; auto add = [&result](const std::vector &entries) { for (const auto &entry : entries) { if (entry.empty() || entry.find('/') != std::string::npos || entry.find('\\') != std::string::npos) { throw_invalid_archive("invalid layout rule entry: " + entry); } result.insert(entry); } }; add(required); add(optional); return result; } struct Validated_Archive_Entry { std::string path; bool is_directory = false; }; struct Archive_Read_Deleter { void operator()(archive *a) const noexcept { if (a != nullptr) { archive_read_free(a); } } }; struct Archive_Write_Deleter { void operator()(archive *a) const noexcept { if (a != nullptr) { archive_write_free(a); } } }; using Read_Archive_Ptr = std::unique_ptr; using Write_Archive_Ptr = std::unique_ptr; Read_Archive_Ptr make_archive_reader() { Read_Archive_Ptr reader(archive_read_new()); if (!reader) { throw std::runtime_error("archive_read_new failed"); } setup_archive_reader(reader.get()); return reader; } bool is_safe_archive_name_token(const std::string &value) { if (value.empty()) { return false; } return std::ranges::all_of(value, [](unsigned char ch) { return std::isalnum(ch) != 0 || ch == '_' || ch == '-' || ch == '.'; }); } void validate_archive_write_options(const Archive_Write_Options &options) { if (!is_safe_archive_name_token(options.format)) { throw std::invalid_argument("invalid archive format: " + options.format); } if (!options.filter.empty() && options.filter != "none" && !is_safe_archive_name_token(options.filter)) { throw std::invalid_argument("invalid archive filter: " + options.filter); } if (!is_safe_archive_name_token(options.extension) || options.extension.find('/') != std::string::npos) { throw std::invalid_argument("invalid archive extension: " + options.extension); } } void configure_archive_writer(archive *writer, const Archive_Write_Options &options) { validate_archive_write_options(options); if (!options.filter.empty() && options.filter != "none") { require_archive_ok( writer, archive_write_add_filter_by_name(writer, options.filter.c_str()), "archive_write_add_filter_by_name failed: " + options.filter); } require_archive_ok( writer, archive_write_set_format_by_name(writer, options.format.c_str()), "archive_write_set_format_by_name failed: " + options.format); if (options.format == "zip") { require_archive_ok(writer, archive_write_zip_set_compression_deflate(writer), "archive_write_zip_set_compression_deflate failed"); } } Write_Archive_Ptr make_archive_writer(const Archive_Write_Options &options) { Write_Archive_Ptr writer(archive_write_new()); if (!writer) { throw std::runtime_error("archive_write_new failed"); } configure_archive_writer(writer.get(), options); return writer; } void extract_archive(archive *a, const fs::path &out_dir) { fs::create_directories(out_dir); const fs::path base = fs::weakly_canonical(out_dir); archive_entry *entry = nullptr; while (true) { const int result = archive_read_next_header(a, &entry); if (result == ARCHIVE_EOF) { break; } if (result != ARCHIVE_OK) { throw std::runtime_error( archive_error_text(a, "archive_read_next_header failed")); } const std::string normalized = entry_path(entry); reject_unsupported_entry_type(entry, normalized); const fs::path out_path = (base / fs::path(normalized)).lexically_normal(); if (!path_is_within(base, out_path)) { throw std::runtime_error("archive-slip detected: " + normalized); } if (is_directory_entry(entry)) { fs::create_directories(out_path); continue; } if (!is_regular_entry(entry)) { throw std::runtime_error("unsupported archive entry type: " + normalized); } fs::create_directories(out_path.parent_path()); std::ofstream out(out_path, std::ios::binary); if (!out) { throw std::runtime_error("open output failed: " + path_to_utf8(out_path)); } const void *block = nullptr; std::size_t block_size = 0; la_int64_t offset = 0; while (true) { const int read_result = archive_read_data_block(a, &block, &block_size, &offset); if (read_result == ARCHIVE_EOF) { break; } if (read_result != ARCHIVE_OK) { throw std::runtime_error( archive_error_text(a, "archive_read_data_block failed")); } out.write(static_cast(block), static_cast(block_size)); if (!out) { throw std::runtime_error("write output failed: " + path_to_utf8(out_path)); } } } } struct Ignore_Rules { std::vector directories; std::unordered_set files; std::unordered_set extensions; }; Ignore_Rules parse_ignore_rules(const std::vector &ignore_patterns) { Ignore_Rules rules; for (const auto &pattern : ignore_patterns) { if (pattern.empty()) continue; if (pattern.ends_with('/')) { rules.directories.emplace_back( fs::path(pattern.substr(0, pattern.size() - 1)).lexically_normal()); } else if (pattern[0] == '.') { rules.extensions.insert(pattern); } else { rules.files.insert(pattern); } } return rules; } bool should_skip_directory(const fs::path &rel_path, const Ignore_Rules &rules) { for (const auto &ignored : rules.directories) { const auto rel_native = rel_path.native(); const auto ignored_native = ignored.native(); if (rel_path == ignored || rel_native.starts_with(ignored_native + fs::path::preferred_separator)) { return true; } } return false; } void add_file_to_archive(archive *writer, const fs::path &base, const fs::path &abs_path, const fs::path &rel_path) { const std::uintmax_t file_size = fs::file_size(abs_path); const std::string archive_name = path_to_utf8(rel_path); if (archive_name.empty() || archive_name == ".." || archive_name.starts_with("../") || archive_name.find("/../") != std::string::npos || archive_name.ends_with("/..")) { throw std::runtime_error("invalid path in archive: " + archive_name); } std::unique_ptr entry( archive_entry_new(), archive_entry_free); if (!entry) { throw std::runtime_error("archive_entry_new failed"); } archive_entry_set_pathname_utf8(entry.get(), archive_name.c_str()); archive_entry_set_filetype(entry.get(), AE_IFREG); archive_entry_set_perm(entry.get(), 0644); archive_entry_set_size(entry.get(), static_cast(file_size)); const auto now = std::chrono::system_clock::to_time_t(std::chrono::system_clock::now()); archive_entry_set_mtime(entry.get(), now, 0); require_archive_ok(writer, archive_write_header(writer, entry.get()), "archive_write_header failed"); std::ifstream in(abs_path, std::ios::binary); if (!in) { throw std::runtime_error("failed to open input file: " + path_to_utf8(abs_path)); } char buffer[kArchiveBlockSize]; while (in) { in.read(buffer, sizeof(buffer)); const auto count = in.gcount(); if (count <= 0) { break; } const la_ssize_t written = archive_write_data(writer, buffer, static_cast(count)); if (written < 0 || written != count) { throw std::runtime_error( archive_error_text(writer, "archive_write_data failed")); } } if (!in.eof()) { throw std::runtime_error("failed to read input file: " + path_to_utf8(abs_path)); } require_archive_ok(writer, archive_write_finish_entry(writer), "archive_write_finish_entry failed"); (void)base; } void write_directory_to_archive( archive *writer, const fs::path &dir_path, const std::vector &ignore_patterns) { if (!fs::exists(dir_path)) { throw std::runtime_error("source path does not exist: " + path_to_utf8(dir_path)); } if (!fs::is_directory(dir_path)) { throw std::runtime_error("source path exists but is not a directory: " + path_to_utf8(dir_path)); } const fs::path base = fs::weakly_canonical(dir_path); const Ignore_Rules ignore_rules = parse_ignore_rules(ignore_patterns); fs::recursive_directory_iterator it(base); const fs::recursive_directory_iterator end; for (; it != end; ++it) { const fs::path abs_path = it->path(); const fs::path rel_path = fs::relative(abs_path, base); if (it->is_directory()) { if (should_skip_directory(rel_path, ignore_rules)) { it.disable_recursion_pending(); } continue; } if (!it->is_regular_file()) { continue; } const std::string filename = abs_path.filename().string(); const std::string extension = abs_path.extension().string(); if (ignore_rules.files.contains(filename) || (!extension.empty() && ignore_rules.extensions.contains(extension))) { continue; } add_file_to_archive(writer, base, abs_path, rel_path); } } la_ssize_t archive_string_write_callback(archive *, void *client_data, const void *buffer, std::size_t length) { auto *out = static_cast(client_data); out->append(static_cast(buffer), length); return static_cast(length); } int archive_noop_open_close_callback(archive *, void *) { return ARCHIVE_OK; } void validate_archive_layout_from_memory_or_throw( const char *data, std::size_t size, const Archive_Layout_Rules &rules) { if (data == nullptr || size == 0) { throw_invalid_archive("empty archive"); } if (size > rules.max_archive_size) { throw_invalid_archive("archive size exceeds limit"); } auto reader = make_archive_reader(); require_archive_ok(reader.get(), archive_read_open_memory(reader.get(), data, size), "archive_read_open_memory failed"); std::vector entries; std::unordered_set unique_paths; std::uint64_t total_uncompressed_size = 0; std::size_t entry_count = 0; archive_entry *entry = nullptr; while (true) { const int result = archive_read_next_header(reader.get(), &entry); if (result == ARCHIVE_EOF) { break; } if (result != ARCHIVE_OK) { throw_invalid_archive( archive_error_text(reader.get(), "cannot read archive header")); } ++entry_count; if (entry_count > rules.max_entry_count) { throw_invalid_archive("entry count exceeds limit"); } const std::string path = entry_path(entry); if (!unique_paths.insert(path).second) { throw_invalid_archive("duplicate entry path: " + path); } if (archive_entry_is_encrypted(entry)) { throw_invalid_archive("encrypted entry is not allowed: " + path); } reject_unsupported_entry_type(entry, path); const bool directory = is_directory_entry(entry); if (!directory) { if (!is_regular_entry(entry)) { throw_invalid_archive("special file is not allowed: " + path); } consume_entry_data_for_validation(reader.get(), entry, path, rules, total_uncompressed_size); } entries.push_back({path, directory}); } for (const auto &rule : rules.directories) { const std::string rule_directory = normalize_archive_path(rule.directory, true); const auto allowed_files = make_allowed_entries(rule.required_files, rule.optional_files); const auto allowed_directories = make_allowed_entries( rule.required_directories, rule.optional_directories); std::unordered_map direct_children; for (const auto &entry : entries) { std::string relative; if (rule_directory.empty()) { relative = entry.path; } else { const std::string prefix = rule_directory + "/"; if (!entry.path.starts_with(prefix)) continue; relative = entry.path.substr(prefix.size()); } const std::size_t slash = relative.find('/'); const std::string child = relative.substr(0, slash); const bool is_directory = slash != std::string::npos || entry.is_directory; const auto [it, inserted] = direct_children.emplace(child, is_directory); if (!inserted && it->second != is_directory) { throw_invalid_archive("entry is both file and directory: " + child); } } for (const auto &[child, is_directory] : direct_children) { const auto &allowed = is_directory ? allowed_directories : allowed_files; if (!allowed.contains(child)) { throw_invalid_archive("unexpected entry under " + rule_directory + ": " + child); } } for (const auto &required : rule.required_files) { const auto it = direct_children.find(required); if (it == direct_children.end() || it->second) { throw_invalid_archive("missing required file under " + rule_directory + ": " + required); } } for (const auto &required : rule.required_directories) { const auto it = direct_children.find(required); if (it == direct_children.end() || !it->second) { throw_invalid_archive("missing required directory under " + rule_directory + ": " + required); } } } } } // namespace Psc::expected validate_archive_layout_from_memory(const char *data, std::size_t size, const Archive_Layout_Rules &rules) { try { validate_archive_layout_from_memory_or_throw(data, size, rules); return {}; } catch (const Invalid_Archive_Error &e) { return Psc::unexpected(e.what()); } } void unarchive_file(const fs::path &zipPath, const fs::path &outDir) { auto reader = make_archive_reader(); #ifdef _WIN32 require_archive_ok(reader.get(), archive_read_open_filename_w(reader.get(), zipPath.wstring().c_str(), kArchiveBlockSize), "archive_read_open_filename_w failed"); #else require_archive_ok(reader.get(), archive_read_open_filename(reader.get(), zipPath.c_str(), kArchiveBlockSize), "archive_read_open_filename failed"); #endif extract_archive(reader.get(), outDir); } void unarchive_from_memory(const char *data, size_t size, const fs::path &outDir) { if (data == nullptr || size == 0) { throw std::runtime_error("empty archive"); } auto reader = make_archive_reader(); require_archive_ok(reader.get(), archive_read_open_memory(reader.get(), data, size), "archive_read_open_memory failed"); extract_archive(reader.get(), outDir); } void archive_directory(const fs::path &dirPath, const fs::path &archive_path, const Archive_Write_Options &options) { auto writer = make_archive_writer(options); #ifdef _WIN32 require_archive_ok(writer.get(), archive_write_open_filename_w( writer.get(), archive_path.wstring().c_str()), "archive_write_open_filename_w failed"); #else require_archive_ok( writer.get(), archive_write_open_filename(writer.get(), archive_path.c_str()), "archive_write_open_filename failed"); #endif write_directory_to_archive(writer.get(), dirPath, {}); require_archive_ok(writer.get(), archive_write_close(writer.get()), "archive_write_close failed"); } std::string archive_directory_to_memory(const fs::path &dirPath, const std::vector &ignorePatterns, const Archive_Write_Options &options) { std::string out; auto writer = make_archive_writer(options); require_archive_ok(writer.get(), archive_write_open(writer.get(), &out, archive_noop_open_close_callback, archive_string_write_callback, archive_noop_open_close_callback), "archive_write_open failed"); write_directory_to_archive(writer.get(), dirPath, ignorePatterns); require_archive_ok(writer.get(), archive_write_close(writer.get()), "archive_write_close failed"); return out; }