# This file is a part of pyctr.
#
# Copyright (c) 2017-2023 Ian Burgwin
# This file is licensed under The MIT License (MIT).
# You can find the full license text in LICENSE in the root of this project.
"""Module for interacting with contents in CDN layout."""
from enum import IntEnum
from os import fsdecode
from pathlib import Path
from typing import TYPE_CHECKING, NamedTuple
from weakref import WeakSet
from fs import open_fs
from fs.base import FS
from fs.osfs import OSFS
from fs.path import dirname as fs_dirname, join as fs_join, basename as fs_basename
from ..common import PyCTRError
from ..crypto import CryptoEngine, Keyslot, add_seed
from .ncch import NCCHReader
from .tmd import TitleMetadataReader
if TYPE_CHECKING:
from os import PathLike
from typing import BinaryIO
from ..common import FilePath
from ..crypto import CBCFileIO
from .tmd import ContentChunkRecord
[docs]
class CDNError(PyCTRError):
"""Generic error for CDN operations."""
[docs]
class CDNSection(IntEnum):
Ticket = -2
"""
Contains the title key used to decrypt the contents, as well as a content index describing which contents are
enabled (mostly used for DLC).
"""
TitleMetadata = -1
"""Contains information about all the possible contents."""
Application = 0
"""Main application CXI."""
Manual = 1
"""Manual CFA. It has a RomFS with a single "Manual.bcma" file inside."""
DownloadPlayChild = 2
"""
Download Play Child CFA. It has a RomFS with CIA files that are sent to other Nintendo 3DS systems using
Download Play. Most games only contain one.
"""
[docs]
class CDNRegion(NamedTuple):
section: 'int | CDNSection'
"""Index of the section."""
iv: bytes
"""Initialization vector. Only used for encrypted contents."""
[docs]
class CDNReader:
"""
Reads the contents of files in a CDN file layout.
Only NCCH contents are supported. SRL (DSiWare) contents are currently ignored.
Note that a custom :class:`~.CryptoEngine` object is only used for encryption on the CDN contents. Each
:class:`~.NCCHReader` must use their own object, as it can only store keys for a single NCCH container. To
use a custom one, set `load_contents` to `False`, then load each section manually with `open_raw_section`.
:param file: A path to a tmd file. All the contents should be in the same directory.
:param case_insensitive: Use case-insensitive paths for the RomFS of each NCCH container.
:param crypto: A custom :class:`~.CryptoEngine` object to be used. Defaults to None, which causes a new one to
be created. This is only used to decrypt the CIA, not the NCCH contents.
:param dev: Use devunit keys.
:param seed: Seed to use. This is a quick way to add a seed using :func:`~.seeddb.add_seed`.
:param titlekey: Encrypted titlekey to use. Used over the ticket file if specified.
:param decrypted_titlekey: Decrypted titlekey to use. Used over the encrypted titlekey or ticket if specified.
:param common_key_index: Common key index to decrypt the titlekey with. Only used if `titlekey` is specified.
Defaults to 0 for an eShop application.
:param load_contents: Load each partition with :class:`~.NCCHReader`.
"""
__slots__ = (
'_base_files', '_crypto', '_open_files', 'available_sections', 'closed', 'content_info', 'contents', 'tmd', 'fs'
)
available_sections: 'list[CDNSection | int]'
"""A list of sections available, including contents, ticket, and title metadata."""
closed: bool
"""`True` if the reader is closed."""
contents: 'dict[int, NCCHReader]'
"""A `dict` of :class:`~.NCCHReader` objects for each active NCCH content."""
content_info: 'list[ContentChunkRecord]'
"""
A list of :class:`~.ContentChunkRecord` objects for each content found in the directory at the time of object
initialization.
"""
tmd: TitleMetadataReader
"""The :class:`~.TitleMetadataReader` object with information from the TMD section."""
def __init__(self, file: 'FilePath', *, fs: 'FS | None' = None, case_insensitive: bool = False,
crypto: 'CryptoEngine' = None, dev: bool = False, seed: bytes = None, titlekey: bytes = None,
decrypted_titlekey: bytes = None, common_key_index: int = 0, load_contents: bool = True):
if crypto:
self._crypto = crypto
else:
self._crypto = CryptoEngine(dev=dev)
self.closed = False
if fs:
if not isinstance(fs, FS):
fs = open_fs(fs)
title_root = fs_dirname(file)
file = fs_basename(file)
else:
# the path being absolute makes Path.parent work reliably
file = Path(file).absolute()
fs = OSFS(fsdecode(file.parent))
title_root = '/'
file = file.name
self.fs = fs
# {section: (filepath, iv)}
self._base_files: dict[CDNSection | int, tuple[str, bytes]] = {}
# opened files to close if the CDNReader is closed
# noinspection PyTypeChecker
self._open_files: set[BinaryIO] = WeakSet()
# public method to see what sections can be accessed
self.available_sections = []
self.contents = {}
self.content_info = []
def add_file(section: 'CDNSection | int', path: 'PathLike | str', iv: 'bytes | None'):
self._base_files[section] = (path, iv)
self.available_sections.append(section)
add_file(CDNSection.TitleMetadata, fs_join(title_root, file), None)
with self.open_raw_section(CDNSection.TitleMetadata) as tmd:
self.tmd = TitleMetadataReader.load(tmd)
if seed:
add_seed(self.tmd.title_id, seed)
if decrypted_titlekey:
self._crypto.set_normal_key(Keyslot.DecryptedTitlekey, decrypted_titlekey)
elif titlekey:
self._crypto.load_encrypted_titlekey(titlekey, common_key_index, self.tmd.title_id)
else:
ticket_file = fs_join(title_root, 'cetk')
add_file(CDNSection.Ticket, ticket_file, None)
with self.open_raw_section(CDNSection.Ticket) as ticket:
self._crypto.load_from_ticket(ticket.read(0x2AC))
for record in self.tmd.chunk_records:
iv = None
if record.type.encrypted:
iv = record.cindex.to_bytes(2, 'big') + (b'\0' * 14)
# check if the content is a Nintendo DS ROM (SRL)
is_srl = record.cindex == 0 and self.tmd.title_id[3:5] == '48'
# allow both lowercase and uppercase contents
content_lower = fs_join(title_root, record.id)
content_upper = fs_join(title_root, record.id.upper())
if fs.isfile(content_lower):
content_file = content_lower
elif fs.isfile(content_upper):
content_file = content_upper
else:
# can't find the file, so continue to the next record
continue
self.content_info.append(record)
add_file(record.cindex, content_file, iv)
# this needs to check how many files are being opened
if load_contents and not is_srl:
decrypted_file = self.open_raw_section(record.cindex)
self.contents[record.cindex] = NCCHReader(decrypted_file, case_insensitive=case_insensitive, dev=dev,
crypto=self._crypto.clone())
def __enter__(self):
return self
def __exit__(self, exc_type, exc_val, exc_tb):
self.close()
[docs]
def close(self):
"""Close the reader."""
if not self.closed:
self.closed = True
for cindex, content in self.contents.items():
content.close()
for f in self._open_files:
f.close()
self.contents = {}
# frozenset can't be modified, so even if I made a mistake this prevents opening files on a closed reader
self._open_files = frozenset()
__del__ = close
def __repr__(self):
info = [('title_id', self.tmd.title_id)]
try:
info.append(('title_name', repr(self.contents[0].exefs.icon.get_app_title().short_desc)))
except KeyError:
info.append(('title_name', 'unknown'))
info.append(('content_count', len(self.contents)))
info_final = " ".join(x + ": " + str(y) for x, y in info)
return f'<{type(self).__name__} {info_final}>'
[docs]
def open_raw_section(self, section: 'int | CDNSection') -> 'BinaryIO':
"""
Open a raw CDN content for reading with on-the-fly decryption.
:param section: The content to open.
:return: A file-like object that reads from the content.
:rtype: io.BufferedIOBase | CBCFileIO
"""
filepath, iv = self._base_files[section]
f = self.fs.open(filepath, 'rb')
if iv: # if encrypted
f = self._crypto.create_cbc_io(Keyslot.DecryptedTitlekey, f, iv, closefd=True)
self._open_files.add(f)
return f