diff --git a/src/core/crypto/ncz_decompressor.cpp b/src/core/crypto/ncz_decompressor.cpp index a183f7ec1e..9b3e43d15c 100644 --- a/src/core/crypto/ncz_decompressor.cpp +++ b/src/core/crypto/ncz_decompressor.cpp @@ -8,7 +8,6 @@ #include #include #include -#include #include #include @@ -121,12 +120,12 @@ std::optional, std::optional>> Parse return std::make_pair(std::move(sections), std::move(bh)); } -// Sequential reader over independently-compressed blocks. Keeps a small LRU cache of recently -// decoded blocks so random reads (e.g. update NCA with thousands of sections) don't re-decompress -// the same block over and over. +// Sequential reader over independently-compressed blocks. Uses a direct-mapped cache (like +// NCZVirtualFile) instead of an LRU list: block_index % CacheSlots picks the slot, so a hit is +// O(1). Reuses one ZSTD_DCtx for the lifetime of the reader. class BlockReader { public: - static constexpr std::size_t BlockCacheSize = 8; + static constexpr std::size_t CacheSlots = 32; BlockReader(FileSys::VirtualFile f, const BlockHeader& bh, u64 payload_start) : source{std::move(f)}, block_size{1ULL << bh.exponent}, sizes{bh.sizes}, @@ -136,9 +135,19 @@ public: u64 off = payload_start; offsets.reserve(sizes.size()); for (u32 s : sizes) { offsets.push_back(off); off += s; } + dctx = ZSTD_createDCtx(); + if (dctx == nullptr) throw std::runtime_error("ZSTD_createDCtx failed"); + cache.resize(CacheSlots); + for (auto& slot : cache) slot.id = ~0ULL; } - // Fills `out` from `position`; returns bytes written. + ~BlockReader() { + if (dctx != nullptr) ZSTD_freeDCtx(dctx); + } + + BlockReader(const BlockReader&) = delete; + BlockReader& operator=(const BlockReader&) = delete; + std::size_t ReadAt(u64 position, std::span out) { std::size_t done = 0; while (done < out.size() && position < decompressed_size) { @@ -158,41 +167,45 @@ public: } private: - const std::vector* GetBlock(u64 id) { - // LRU hit: move to front. - for (auto it = cache.begin(); it != cache.end(); ++it) { - if (it->first == id) { - cache.splice(cache.begin(), cache, it); - return &cache.front().second; - } - } + struct Slot { + u64 id = ~0ULL; + std::vector data; + }; - // Miss: decode the block. - std::vector buffer; - if (cache.size() >= BlockCacheSize) { - buffer = std::move(cache.back().second); - cache.pop_back(); + const std::vector* GetBlock(u64 id) { + Slot& slot = cache[id % CacheSlots]; + if (slot.id == id) { + return &slot.data; // O(1) hit } + std::vector buffer; + buffer.swap(slot.data); // reuse existing allocation if (!DecodeBlock(id, buffer)) { return nullptr; } - cache.emplace_front(id, std::move(buffer)); - return &cache.front().second; + slot.data = std::move(buffer); + slot.id = id; + return &slot.data; } bool DecodeBlock(u64 id, std::vector& buffer) { u64 want = block_size; if (id + 1 == sizes.size()) { const u64 rem = decompressed_size % block_size; - if (rem > 0) want = rem; // avoid the 0-size bug from the original nsz + if (rem > 0) want = rem; // avoid the 0-size bug from the original nsz } try { buffer.resize(static_cast(want)); } catch (const std::bad_alloc&) { return false; } const u32 csize = sizes[id]; if (csize < want) { const auto comp = source->ReadBytes(csize, offsets[id]); - if (comp.size() != csize || - Common::Compression::DecompressDataZSTD(comp, buffer) != want) return false; + if (comp.size() != csize) return false; + const std::size_t res = ZSTD_decompressDCtx( + dctx, buffer.data(), buffer.size(), comp.data(), comp.size()); + if (ZSTD_isError(res) || res != want) { + LOG_ERROR(Crypto, "Failed to decompress NCZBLOCK block {}: {}", id, + ZSTD_isError(res) ? ZSTD_getErrorName(res) : "size mismatch"); + return false; + } } else if (source->Read(buffer.data(), buffer.size(), offsets[id]) != buffer.size()) { return false; } @@ -204,7 +217,8 @@ private: std::vector offsets; std::vector sizes; u64 decompressed_size; - std::list>> cache; // LRU: front = most recent + ZSTD_DCtx* dctx = nullptr; + std::vector cache; // direct-mapped, CacheSlots entries }; class BlockNczFile final : public FileSys::VfsFile { @@ -216,10 +230,6 @@ public: nca_size = HeaderSize; for (const auto& s : sections) nca_size = std::max(nca_size, static_cast(s.offset + s.size)); - ciphers.reserve(sections.size()); - for (const auto& s : sections) - ciphers.push_back(s.Ctr() ? std::make_unique>(s.key, Mode::CTR) - : nullptr); } std::string GetName() const override { return name; } @@ -229,6 +239,10 @@ public: bool IsWritable() const override { return false; } bool IsReadable() const override { return true; } + // NCZ stores CTR sections decrypted; this file serves them as-is, and NcaFileSystemDriver + // skips its AES layers because of this flag. + bool HasDecryptedSections() const override { return true; } + std::size_t Read(u8* data, std::size_t len, std::size_t off) const override { if (off >= nca_size) return 0; len = static_cast(std::min(len, nca_size - off)); @@ -240,8 +254,15 @@ public: pos += n; } std::scoped_lock lock{mutex}; - for (std::size_t i = 0; i < sections.size() && pos < end; ++i) { - const auto& s = sections[i]; + // Sections are sorted by offset; find the first one whose stop > pos. + auto it = std::lower_bound( + sections.begin(), sections.end(), pos, [](const Section& s, u64 p) { + const u64 b = std::max(static_cast(s.offset), HeaderSize); + const u64 e = std::max(static_cast(s.offset) + static_cast(s.size), b); + return e <= p; + }); + for (; it != sections.end() && pos < end; ++it) { + const auto& s = *it; const u64 begin = std::max(static_cast(s.offset), HeaderSize); const u64 stop = std::max(static_cast(s.offset + s.size), begin); if (stop <= pos) continue; @@ -255,12 +276,7 @@ public: const std::span piece(data + (pos - off), static_cast(pe - pos)); if (body->ReadAt(pos - HeaderSize, piece) != piece.size()) return static_cast(pos - off); - if (ciphers[i]) { - Key128 iv{}; - SetIv(iv, s.counter, pos); - ciphers[i]->SetIV(iv); - ciphers[i]->Transcode(piece.data(), piece.size(), piece.data(), Op::Encrypt); - } + // No CTR here: NCZ body is already decrypted, and NcaFileSystemDriver skips AES. pos = pe; } return len; @@ -274,7 +290,6 @@ private: const std::vector hdr; const std::vector
sections; const std::unique_ptr body; - std::vector>> ciphers; mutable std::mutex mutex; u64 nca_size{}; }; diff --git a/src/core/file_sys/fssystem/fssystem_nca_file_system_driver.cpp b/src/core/file_sys/fssystem/fssystem_nca_file_system_driver.cpp index d496d58cec..f3462dc8a3 100644 --- a/src/core/file_sys/fssystem/fssystem_nca_file_system_driver.cpp +++ b/src/core/file_sys/fssystem/fssystem_nca_file_system_driver.cpp @@ -1,4 +1,4 @@ -// SPDX-FileCopyrightText: Copyright 2025 Eden Emulator Project +// SPDX-FileCopyrightText: Copyright 2026 Eden Emulator Project // SPDX-License-Identifier: GPL-3.0-or-later // SPDX-FileCopyrightText: Copyright 2023 yuzu Emulator Project @@ -93,6 +93,11 @@ Result NcaFileSystemDriver::OpenStorageImpl(VirtualFile* out, NcaFsHeaderReader* // Initialize our header reader for the fs index. R_TRY(out_header_reader->Initialize(*m_reader, fs_index)); + // NCZ files serve already-decrypted body data (CTR sections are stored decrypted). + // Skip the AES layers so we don't decrypt plaintext twice. + const bool is_pre_decrypted = + m_reader->GetStorage() && m_reader->GetStorage()->HasDecryptedSections(); + // Declare the storage we're opening. VirtualFile storage; @@ -158,7 +163,7 @@ Result NcaFileSystemDriver::OpenStorageImpl(VirtualFile* out, NcaFsHeaderReader* out_header_reader->GetPatchMetaDataHashDataInfo())); } - if (patch_info.HasAesCtrExTable()) { + if (patch_info.HasAesCtrExTable() && !is_pre_decrypted) { // Check the encryption type. ASSERT(out_header_reader->GetEncryptionType() == NcaFsHeader::EncryptionType::None || out_header_reader->GetEncryptionType() == NcaFsHeader::EncryptionType::AesCtrEx || @@ -196,32 +201,34 @@ Result NcaFileSystemDriver::OpenStorageImpl(VirtualFile* out, NcaFsHeaderReader* } } else { // Create the appropriate storage for the encryption type. - switch (out_header_reader->GetEncryptionType()) { - case NcaFsHeader::EncryptionType::None: - // If there's no encryption, use the base storage we made previously. - break; - case NcaFsHeader::EncryptionType::AesXts: - R_TRY(this->CreateAesXtsStorage(std::addressof(storage), std::move(storage), - fs_data_offset)); - break; - case NcaFsHeader::EncryptionType::AesCtr: - R_TRY(this->CreateAesCtrStorage(std::addressof(storage), std::move(storage), - fs_data_offset, out_header_reader->GetAesCtrUpperIv(), - AlignmentStorageRequirement::None)); - break; - case NcaFsHeader::EncryptionType::AesCtrSkipLayerHash: { - // Create the aes ctr storage. - VirtualFile aes_ctr_storage; - R_TRY(this->CreateAesCtrStorage(std::addressof(aes_ctr_storage), storage, - fs_data_offset, out_header_reader->GetAesCtrUpperIv(), - AlignmentStorageRequirement::None)); + if (!is_pre_decrypted) { + switch (out_header_reader->GetEncryptionType()) { + case NcaFsHeader::EncryptionType::None: + // If there's no encryption, use the base storage we made previously. + break; + case NcaFsHeader::EncryptionType::AesXts: + R_TRY(this->CreateAesXtsStorage(std::addressof(storage), std::move(storage), + fs_data_offset)); + break; + case NcaFsHeader::EncryptionType::AesCtr: + R_TRY(this->CreateAesCtrStorage(std::addressof(storage), std::move(storage), + fs_data_offset, out_header_reader->GetAesCtrUpperIv(), + AlignmentStorageRequirement::None)); + break; + case NcaFsHeader::EncryptionType::AesCtrSkipLayerHash: { + // Create the aes ctr storage. + VirtualFile aes_ctr_storage; + R_TRY(this->CreateAesCtrStorage(std::addressof(aes_ctr_storage), storage, + fs_data_offset, out_header_reader->GetAesCtrUpperIv(), + AlignmentStorageRequirement::None)); - // Create region switch storage. - R_TRY(this->CreateRegionSwitchStorage(std::addressof(storage), out_header_reader, - std::move(storage), std::move(aes_ctr_storage))); - } break; - default: - R_THROW(ResultInvalidNcaFsHeaderEncryptionType); + // Create region switch storage. + R_TRY(this->CreateRegionSwitchStorage(std::addressof(storage), out_header_reader, + std::move(storage), std::move(aes_ctr_storage))); + } break; + default: + R_THROW(ResultInvalidNcaFsHeaderEncryptionType); + } } // Potentially save storages to our context. @@ -382,21 +389,25 @@ Result NcaFileSystemDriver::OpenIndirectableStorageAsOriginal( } // Create the appropriate storage for the encryption type. - switch (header_reader->GetEncryptionType()) { - case NcaFsHeader::EncryptionType::None: - // If there's no encryption, use the base storage we made previously. - break; - case NcaFsHeader::EncryptionType::AesXts: - R_TRY( - this->CreateAesXtsStorage(std::addressof(storage), std::move(storage), fs_data_offset)); - break; - case NcaFsHeader::EncryptionType::AesCtr: - R_TRY(this->CreateAesCtrStorage(std::addressof(storage), std::move(storage), fs_data_offset, - header_reader->GetAesCtrUpperIv(), - AlignmentStorageRequirement::CacheBlockSize)); - break; - default: - R_THROW(ResultInvalidNcaFsHeaderEncryptionType); + const bool is_pre_decrypted = + m_reader->GetStorage() && m_reader->GetStorage()->HasDecryptedSections(); + if (!is_pre_decrypted) { + switch (header_reader->GetEncryptionType()) { + case NcaFsHeader::EncryptionType::None: + // If there's no encryption, use the base storage we made previously. + break; + case NcaFsHeader::EncryptionType::AesXts: + R_TRY(this->CreateAesXtsStorage(std::addressof(storage), std::move(storage), + fs_data_offset)); + break; + case NcaFsHeader::EncryptionType::AesCtr: + R_TRY(this->CreateAesCtrStorage(std::addressof(storage), std::move(storage), + fs_data_offset, header_reader->GetAesCtrUpperIv(), + AlignmentStorageRequirement::CacheBlockSize)); + break; + default: + R_THROW(ResultInvalidNcaFsHeaderEncryptionType); + } } // Set output storage. @@ -432,6 +443,11 @@ Result NcaFileSystemDriver::CreateAesCtrStorage( ASSERT(out != nullptr); ASSERT(base_storage != nullptr); + if (m_reader->GetStorage() && m_reader->GetStorage()->HasDecryptedSections()) { + *out = std::move(base_storage); + R_SUCCEED(); + } + // Create the iv. std::array iv{}; AesCtrStorage::MakeIv(iv.data(), sizeof(iv), upper_iv.value, offset); @@ -469,6 +485,11 @@ Result NcaFileSystemDriver::CreateAesXtsStorage(VirtualFile* out, VirtualFile ba ASSERT(out != nullptr); ASSERT(base_storage != nullptr); + if (m_reader->GetStorage() && m_reader->GetStorage()->HasDecryptedSections()) { + *out = std::move(base_storage); + R_SUCCEED(); + } + // Create the iv. std::array iv{}; AesXtsStorage::MakeAesXtsIv(iv.data(), sizeof(iv), offset, NcaHeader::XtsBlockSize); @@ -809,6 +830,12 @@ Result NcaFileSystemDriver::CreateAesCtrExStorageMetaStorage( // Validate preconditions. ASSERT(out != nullptr); ASSERT(base_storage != nullptr); + + if (m_reader->GetStorage() && m_reader->GetStorage()->HasDecryptedSections()) { + *out = std::move(base_storage); + R_SUCCEED(); + } + ASSERT(encryption_type == NcaFsHeader::EncryptionType::None || encryption_type == NcaFsHeader::EncryptionType::AesCtrEx || encryption_type == NcaFsHeader::EncryptionType::AesCtrExSkipLayerHash); @@ -871,6 +898,11 @@ Result NcaFileSystemDriver::CreateAesCtrExStorage( ASSERT(meta_storage != nullptr); ASSERT(patch_info.HasAesCtrExTable()); + if (m_reader->GetStorage() && m_reader->GetStorage()->HasDecryptedSections()) { + *out = std::move(base_storage); + R_SUCCEED(); + } + // Read the bucket tree header. BucketTree::Header header; std::memcpy(std::addressof(header), patch_info.aes_ctr_ex_header.data(), sizeof(header)); diff --git a/src/core/file_sys/fssystem/fssystem_nca_file_system_driver.h b/src/core/file_sys/fssystem/fssystem_nca_file_system_driver.h index ad8095b6f0..74f969154f 100644 --- a/src/core/file_sys/fssystem/fssystem_nca_file_system_driver.h +++ b/src/core/file_sys/fssystem/fssystem_nca_file_system_driver.h @@ -104,6 +104,7 @@ public: const NcaCompressionConfiguration& compression_cfg); VirtualFile GetSharedBodyStorage(); + VirtualFile GetStorage() const { return m_body_storage; } u32 GetMagic() const; NcaHeader::DistributionType GetDistributionType() const; NcaHeader::ContentType GetContentType() const; diff --git a/src/core/file_sys/vfs/vfs.h b/src/core/file_sys/vfs/vfs.h index c3d73364bc..cd8eebcd8c 100644 --- a/src/core/file_sys/vfs/vfs.h +++ b/src/core/file_sys/vfs/vfs.h @@ -112,6 +112,9 @@ public: // Returns whether or not the file can be read from. virtual bool IsReadable() const = 0; + // Returns true if this file serves NCA body data that is already decrypted (e.g. NCZ). + virtual bool HasDecryptedSections() const { return false; } + // The primary method of reading from the file. Reads length bytes into data starting at offset // into file. Returns number of bytes successfully read. virtual std::size_t Read(u8* data, std::size_t length, std::size_t offset = 0) const = 0; diff --git a/src/core/file_sys/vfs/vfs_offset.h b/src/core/file_sys/vfs/vfs_offset.h index 9800b4fd09..3083c6a88a 100644 --- a/src/core/file_sys/vfs/vfs_offset.h +++ b/src/core/file_sys/vfs/vfs_offset.h @@ -37,6 +37,10 @@ public: std::size_t GetOffset() const; + bool HasDecryptedSections() const override { + return file && file->HasDecryptedSections(); + } + private: std::size_t TrimToFit(std::size_t r_size, std::size_t r_offset) const;