class CDNContentsMount(LoggingMixIn, Operations): fd = 0 # get the real path by returning self.cdn_dir + path def rp(self, path): return os.path.join(self.cdn_dir, path) def __init__(self, tmd_file: str = None, cdn_dir: str = None, dec_key: str = None, dev: bool = False, seeddb: str = None, boot9: str = None): if tmd_file: self.cdn_dir = os.path.dirname(tmd_file) else: self.cdn_dir = cdn_dir tmd_file = os.path.join(cdn_dir, 'tmd') self.crypto = CryptoEngine(boot9=boot9, dev=dev) self.cdn_content_size = 0 self.dev = dev self.seeddb = seeddb # get status change, modify, and file access times try: self.g_stat = get_time(tmd_file) except FileNotFoundError: exit('Could not find "tmd" in directory') self.tmd = TitleMetadataReader.from_file(tmd_file) # noinspection PyUnboundLocalVariable self.title_id = self.tmd.title_id if not os.path.isfile(self.rp('cetk')): if not dec_key: exit('cetk not found. Provide the ticket or decrypted titlekey with --dec-key.') if dec_key: try: titlekey = bytes.fromhex(dec_key) if len(titlekey) != 16: exit('--dec-key input is not 32 hex characters.') except ValueError: exit('Failed to convert --dec-key input to bytes. Non-hex character likely found, or is not ' '32 hex characters.') # noinspection PyUnboundLocalVariable self.crypto.set_normal_key(Keyslot.DecryptedTitlekey, titlekey) else: with open(self.rp('cetk'), 'rb') as tik: # load ticket self.crypto.load_from_ticket(tik.read(0x350)) # create virtual files self.files = {'/ticket.bin': {'size': 0x350, 'type': 'raw', 'real_filepath': self.rp('cetk')}, '/tmd.bin': {'size': 0xB04 + self.tmd.content_count * CHUNK_RECORD_SIZE, 'offset': 0, 'type': 'raw', 'real_filepath': self.rp('tmd')}, '/tmdchunks.bin': {'size': self.tmd.content_count * CHUNK_RECORD_SIZE, 'offset': 0xB04, 'type': 'raw', 'real_filepath': self.rp('tmd')}} self.dirs: Dict[str, NCCHContainerMount] = {} def init(self, path): # read contents to generate virtual files for chunk in self.tmd.chunk_records: if os.path.isfile(self.rp(chunk.id)): real_filename = chunk.id elif os.path.isfile(self.rp(chunk.id.upper())): real_filename = chunk.id.upper() else: print(f'Content {chunk.cindex:04}:{chunk.id} not found, will not be included.') continue f_stat = os.stat(self.rp(real_filename)) if chunk.size != f_stat.st_size: print('Warning: TMD Content size and filesize of', chunk.id, 'are different.') self.cdn_content_size += chunk.size is_srl = chunk.cindex == 0 and self.title_id[3:5] == '48' file_ext = 'nds' if is_srl else 'ncch' filename = f'/{chunk.cindex:04x}.{chunk.id}.{file_ext}' self.files[filename] = {'size': chunk.size, 'index': chunk.cindex.to_bytes(2, 'big'), 'type': 'enc' if chunk.type.encrypted else 'raw', 'real_filepath': self.rp(real_filename)} dirname = f'/{chunk.cindex:04x}.{chunk.id}' # noinspection PyBroadException try: f_time = get_time(f_stat) content_vfp = _c.VirtualFileWrapper(self, filename, chunk.size) # boot9 is not passed here, as CryptoEngine has already set up the keys at the beginning. if is_srl: content_fuse = SRLMount(content_vfp, g_stat=f_time) else: content_reader = NCCHReader(content_vfp, dev=self.dev, seeddb=self.seeddb) content_fuse = NCCHContainerMount(content_reader, g_stat=f_time) content_fuse.init(path) self.dirs[dirname] = content_fuse except Exception as e: print(f'Failed to mount {filename}: {type(e).__name__}: {e}') @_c.ensure_lower_path def getattr(self, path, fh=None): first_dir = _c.get_first_dir(path) if first_dir in self.dirs: return self.dirs[first_dir].getattr(_c.remove_first_dir(path), fh) uid, gid, pid = fuse_get_context() if path == '/' or path in self.dirs: st = {'st_mode': (S_IFDIR | 0o555), 'st_nlink': 2} elif path in self.files: st = {'st_mode': (S_IFREG | 0o444), 'st_size': self.files[path]['size'], 'st_nlink': 1} else: raise FuseOSError(ENOENT) return {**st, **self.g_stat, 'st_uid': uid, 'st_gid': gid} def open(self, path, flags): self.fd += 1 return self.fd @_c.ensure_lower_path def readdir(self, path, fh): first_dir = _c.get_first_dir(path) if first_dir in self.dirs: yield from self.dirs[first_dir].readdir(_c.remove_first_dir(path), fh) else: yield from ('.', '..') yield from (x[1:] for x in self.files) yield from (x[1:] for x in self.dirs) @_c.ensure_lower_path def read(self, path, size, offset, fh): first_dir = _c.get_first_dir(path) if first_dir in self.dirs: return self.dirs[first_dir].read(_c.remove_first_dir(path), size, offset, fh) fi = self.files[path] real_size = size with open(fi['real_filepath'], 'rb') as f: if fi['type'] == 'raw': # if raw, just read and return f.seek(offset) data = f.read(size) elif fi['type'] == 'enc': # if encrypted, the block needs to be decrypted first # CBC requires a full block (0x10 in this case). and the previous # block is used as the IV. so that's quite a bit to read if the # application requires just a few bytes. # thanks Stary2001 before = offset % 16 if size % 16 != 0: size = size + 16 - size % 16 if offset - before == 0: # use the initial value if reading from the first block iv = fi['index'] + (b'\0' * 14) else: # use the previous block if reading anywhere else f.seek(offset - before - 0x10) iv = f.read(0x10) # read to block size f.seek(offset - before) # adding 0x10 to the size fixes some kind of decryption bug data = self.crypto.create_cbc_cipher(Keyslot.DecryptedTitlekey, iv).decrypt(f.read(size + 0x10))[before:real_size + before] else: from pprint import pformat print('--------------------------------------------------', 'Warning: unknown file type (this should not happen!)', 'Please file an issue or contact the developer with the details below.', ' https://github.com/ihaveamac/ninfs/issues', '--------------------------------------------------', f'{path!r}: {pformat(fi)!r}', sep='\n') data = b'g' * size return data @_c.ensure_lower_path def statfs(self, path): first_dir = _c.get_first_dir(path) if first_dir in self.dirs: return self.dirs[first_dir].read(_c.remove_first_dir(path)) return {'f_bsize': 4096, 'f_blocks': self.cdn_content_size // 4096, 'f_bavail': 0, 'f_bfree': 0, 'f_files': len(self.files)}
class CTRImportableArchiveMount(LoggingMixIn, Operations): fd = 0 def __init__(self, cia_fp: BinaryIO, g_stat: os.stat_result, dev: bool = False, seeddb: bool = None): self.crypto = CryptoEngine(dev=dev) self.dev = dev self.seeddb = seeddb self._g_stat = g_stat # get status change, modify, and file access times self.g_stat = { 'st_ctime': int(g_stat.st_ctime), 'st_mtime': int(g_stat.st_mtime), 'st_atime': int(g_stat.st_atime) } # open cia and get section sizes archive_header_size, cia_type, cia_version, cert_chain_size, \ ticket_size, tmd_size, meta_size, content_size = unpack('<IHHIIIIQ', cia_fp.read(0x20)) self.cia_size = new_offset(archive_header_size) + new_offset(cert_chain_size) + new_offset(ticket_size)\ + new_offset(tmd_size) + new_offset(meta_size) + new_offset(content_size) # get offsets for sections of the CIA # each section is aligned to 64-byte blocks cert_chain_offset = new_offset(archive_header_size) ticket_offset = cert_chain_offset + new_offset(cert_chain_size) tmd_offset = ticket_offset + new_offset(ticket_size) self.content_offset = tmd_offset + new_offset(tmd_size) meta_offset = self.content_offset + new_offset(content_size) # load tmd cia_fp.seek(tmd_offset) self.tmd = TitleMetadataReader.load(cia_fp) self.title_id = self.tmd.title_id # load titlekey cia_fp.seek(ticket_offset) self.crypto.load_from_ticket(cia_fp.read(ticket_size)) # create virtual files self.files = { '/header.bin': { 'size': archive_header_size, 'offset': 0, 'type': 'raw' }, '/cert.bin': { 'size': cert_chain_size, 'offset': cert_chain_offset, 'type': 'raw' }, '/ticket.bin': { 'size': ticket_size, 'offset': ticket_offset, 'type': 'raw' }, '/tmd.bin': { 'size': tmd_size, 'offset': tmd_offset, 'type': 'raw' }, '/tmdchunks.bin': { 'size': self.tmd.content_count * CHUNK_RECORD_SIZE, 'offset': tmd_offset + 0xB04, 'type': 'raw' } } if meta_size: self.files['/meta.bin'] = { 'size': meta_size, 'offset': meta_offset, 'type': 'raw' } # show icon.bin if meta size is the expected size # in practice this never changes, but better to be safe if meta_size == 0x3AC0: self.files['/icon.bin'] = { 'size': 0x36C0, 'offset': meta_offset + 0x400, 'type': 'raw' } self.dirs: Dict[str, NCCHContainerMount] = {} self.f = cia_fp def __del__(self, *args): try: self.f.close() except AttributeError: pass destroy = __del__ def init(self, path): # read chunks to generate virtual files current_offset = self.content_offset for chunk in self.tmd.chunk_records: is_srl = chunk.cindex == 0 and self.title_id[3:5] == '48' file_ext = 'nds' if is_srl else 'ncch' filename = f'/{chunk.cindex:04x}.{chunk.id}.{file_ext}' self.files[filename] = { 'size': chunk.size, 'offset': current_offset, 'index': chunk.cindex.to_bytes(2, 'big'), 'type': 'enc' if chunk.type.encrypted else 'raw' } current_offset += new_offset(chunk.size) dirname = f'/{chunk.cindex:04x}.{chunk.id}' # noinspection PyBroadException try: content_vfp = _c.VirtualFileWrapper(self, filename, chunk.size) if is_srl: content_fuse = SRLMount(content_vfp, g_stat=self._g_stat) else: content_fuse = NCCHContainerMount(content_vfp, dev=self.dev, g_stat=self._g_stat, seeddb=self.seeddb) content_fuse.init(path) self.dirs[dirname] = content_fuse except Exception as e: print(f'Failed to mount {filename}: {type(e).__name__}: {e}') def flush(self, path, fh): return self.f.flush() @_c.ensure_lower_path def getattr(self, path, fh=None): first_dir = _c.get_first_dir(path) if first_dir in self.dirs: return self.dirs[first_dir].getattr(_c.remove_first_dir(path), fh) uid, gid, pid = fuse_get_context() if path == '/' or path in self.dirs: st = {'st_mode': (S_IFDIR | 0o555), 'st_nlink': 2} elif path in self.files: st = { 'st_mode': (S_IFREG | 0o444), 'st_size': self.files[path]['size'], 'st_nlink': 1 } else: raise FuseOSError(ENOENT) return {**st, **self.g_stat, 'st_uid': uid, 'st_gid': gid} def open(self, path, flags): self.fd += 1 return self.fd @_c.ensure_lower_path def readdir(self, path, fh): first_dir = _c.get_first_dir(path) if first_dir in self.dirs: yield from self.dirs[first_dir].readdir(_c.remove_first_dir(path), fh) else: yield from ('.', '..') yield from (x[1:] for x in self.files) yield from (x[1:] for x in self.dirs) @_c.ensure_lower_path def read(self, path, size, offset, fh): first_dir = _c.get_first_dir(path) if first_dir in self.dirs: return self.dirs[first_dir].read(_c.remove_first_dir(path), size, offset, fh) fi = self.files[path] real_offset = fi['offset'] + offset if fi['offset'] + offset > fi['offset'] + fi['size']: return b'' if offset + size > fi['size']: size = fi['size'] - offset real_size = size if fi['type'] == 'raw': # if raw, just read and return self.f.seek(real_offset) data = self.f.read(size) elif fi['type'] == 'enc': # if encrypted, the block needs to be decrypted first # CBC requires a full block (0x10 in this case). and the previous # block is used as the IV. so that's quite a bit to read if the # application requires just a few bytes. # thanks Stary2001 before = offset % 16 if size % 16 != 0: size = size + 16 - size % 16 if offset - before == 0: # use the initial value if reading from the first block # noinspection PyTypeChecker iv = fi['index'] + (b'\0' * 14) else: # use the previous block if reading anywhere else self.f.seek(real_offset - before - 0x10) iv = self.f.read(0x10) # read to block size self.f.seek(real_offset - before) # adding 0x10 to the size fixes some kind of decryption bug data = self.crypto.create_cbc_cipher( Keyslot.DecryptedTitlekey, iv).decrypt( self.f.read(size + 0x10))[before:real_size + before] else: from pprint import pformat print( '--------------------------------------------------', 'Warning: unknown file type (this should not happen!)', 'Please file an issue or contact the developer with the details below.', ' https://github.com/ihaveamac/ninfs/issues', '--------------------------------------------------', f'{path!r}: {pformat(fi)!r}', sep='\n') data = b'g' * size return data @_c.ensure_lower_path def statfs(self, path): first_dir = _c.get_first_dir(path) if first_dir in self.dirs: return self.dirs[first_dir].statfs(_c.remove_first_dir(path)) return { 'f_bsize': 4096, 'f_blocks': self.cia_size // 4096, 'f_bavail': 0, 'f_bfree': 0, 'f_files': len(self.files) }