Compare commits

...

4 Commits

  1. 1
      README.md
  2. 1
      ninfs/gui/setupwizard/__init__.py
  3. 73
      ninfs/gui/setupwizard/sdtitle.py
  4. 1
      ninfs/gui/wizardcontainer.py
  5. 56
      ninfs/mount/exefs.py
  6. 5
      ninfs/mount/sd.py
  7. 194
      ninfs/mount/sdtitle.py
  8. 6
      ninfs/mountinfo.py
  9. 2
      requirements.txt
  10. 2
      setup.py

1
README.md

@ -15,6 +15,7 @@ Windows, macOS, and Linux are supported. @@ -15,6 +15,7 @@ Windows, macOS, and Linux are supported.
* NCCH (".cxi", ".cfa", ".ncch", ".app")
* Read-only Filesystem (".romfs", "romfs.bin")
* SD Card Contents ("Nintendo 3DS" from SD)
* Installed SD Title Contents ("\*.tmd" and "\*.app" files)
* 3DSX Homebrew (".3dsx")
* Nintendo DS / DSi
* Nintendo DSi NAND backup ("nand\_dsi.bin")

1
ninfs/gui/setupwizard/__init__.py

@ -17,5 +17,6 @@ from .nandbb import BBNandImageSetup @@ -17,5 +17,6 @@ from .nandbb import BBNandImageSetup
from .ncch import NCCHSetup
from .romfs import RomFSSetup
from .sd import SDFilesystemSetup
from .sdtitle import SDTitleSetup
from .srl import SRLSetup
from .threedsx import ThreeDSXSetup

73
ninfs/gui/setupwizard/sdtitle.py

@ -0,0 +1,73 @@ @@ -0,0 +1,73 @@
# This file is a part of ninfs.
#
# Copyright (c) 2017-2021 Ian Burgwin
# This file is licensed under The MIT License (MIT).
# You can find the full license text in LICENSE.md in the root of this project.
import tkinter as tk
from typing import TYPE_CHECKING
from .base import WizardBase
from .. import supportfiles
if TYPE_CHECKING:
from .. import WizardContainer
class SDTitleSetup(WizardBase):
def __init__(self, parent: 'tk.BaseWidget' = None, *, wizardcontainer: 'WizardContainer'):
super().__init__(parent, wizardcontainer=wizardcontainer)
def callback(*_):
main_file = self.main_textbox_var.get().strip()
b9_file = self.b9_textbox_var.get().strip()
self.wizardcontainer.set_next_enabled(main_file and b9_file)
main_container, main_textbox, main_textbox_var = self.make_file_picker('Select the SD Title TMD file:',
'Select SD Title TMD file')
main_container.pack(fill=tk.X, expand=True)
b9_container, b9_textbox, b9_textbox_var = self.make_file_picker('Select the boot9 file:', 'Select boot9 file')
b9_container.pack(fill=tk.X, expand=True)
seeddb_container, seeddb_textbox, seeddb_textbox_var = self.make_file_picker('Select the seeddb file:',
'Select seeddb file')
seeddb_container.pack(fill=tk.X, expand=True)
seed_container, seed_textbox, seed_textbox_var = self.make_entry('OR Enter the seed '
'(optional if seeddb is used):')
seed_container.pack(fill=tk.X, expand=True)
self.main_textbox_var = main_textbox_var
self.b9_textbox_var = b9_textbox_var
self.seeddb_textbox_var = seeddb_textbox_var
self.seed_textbox_var = seed_textbox_var
main_textbox_var.trace_add('write', callback)
b9_textbox_var.trace_add('write', callback)
b9_textbox_var.set(supportfiles.last_b9_file)
seeddb_textbox_var.set(supportfiles.last_seeddb_file)
self.set_header_suffix('SD Title')
def next_pressed(self):
main_file = self.main_textbox_var.get().strip()
b9_file = self.b9_textbox_var.get().strip()
seeddb_file = self.seeddb_textbox_var.get().strip()
seed = self.seed_textbox_var.get().replace(' ', '')
if b9_file:
supportfiles.last_b9_file = b9_file
if seeddb_file:
supportfiles.last_seeddb_file = seeddb_file
args = ['sdtitle', main_file]
if b9_file:
args += ['--boot9', b9_file]
if seed:
args += ['--seed', seed]
elif seeddb_file:
args += ['--seeddb', seeddb_file]
self.wizardcontainer.show_mount_point_selector('SD Title', args)

1
ninfs/gui/wizardcontainer.py

@ -33,6 +33,7 @@ wizard_bases = { @@ -33,6 +33,7 @@ wizard_bases = {
'ncch': NCCHSetup,
'romfs': RomFSSetup,
'sd': SDFilesystemSetup,
'sdtitle': SDTitleSetup,
'srl': SRLSetup,
'threedsx': ThreeDSXSetup,
}

56
ninfs/mount/exefs.py

@ -9,25 +9,28 @@ Mounts Executable Filesystem (ExeFS) files, creating a virtual filesystem of the @@ -9,25 +9,28 @@ Mounts Executable Filesystem (ExeFS) files, creating a virtual filesystem of the
"""
import logging
import os
from errno import ENOENT
from io import BytesIO
from stat import S_IFDIR, S_IFREG
from sys import argv
from threading import Lock
from typing import TYPE_CHECKING
from pyctr.type.exefs import ExeFSReader, ExeFSFileNotFoundError, CodeDecompressionError
from pyctr.type.smdh import SMDH, InvalidSMDHError
from . import _common as _c
# _common imports these from fusepy, and prints an error if it fails; this allows less duplicated code
from ._common import FUSE, FuseOSError, Operations, LoggingMixIn, fuse_get_context, get_time, realpath
if TYPE_CHECKING:
from typing import Dict
from typing import BinaryIO, Dict, Union
class ExeFSMount(LoggingMixIn, Operations):
fd = 0
files: 'Dict[str, str]'
special_files: 'Dict[str, Dict[str, Union[int, BinaryIO]]]'
def __init__(self, reader: 'ExeFSReader', g_stat: dict, decompress_code: bool = False):
self.g_stat = g_stat
@ -35,6 +38,8 @@ class ExeFSMount(LoggingMixIn, Operations): @@ -35,6 +38,8 @@ class ExeFSMount(LoggingMixIn, Operations):
self.reader = reader
self.decompress_code = decompress_code
self.special_files_lock = Lock()
# for vfs stats
self.exefs_size = sum(x.size for x in self.reader.entries.values())
@ -44,6 +49,10 @@ class ExeFSMount(LoggingMixIn, Operations): @@ -44,6 +49,10 @@ class ExeFSMount(LoggingMixIn, Operations):
except AttributeError:
pass
with self.special_files_lock:
for f in self.special_files.values():
f['io'].close()
destroy = __del__
# TODO: maybe do this in a way that allows for multiprocessing (titledir)
@ -62,6 +71,27 @@ class ExeFSMount(LoggingMixIn, Operations): @@ -62,6 +71,27 @@ class ExeFSMount(LoggingMixIn, Operations):
# displayed name associated with real entry name
self.files = {'/' + x.name.replace('.', '', 1) + '.bin': x.name for x in self.reader.entries.values()}
self.special_files = {}
if 'icon' in self.reader.entries:
try:
with self.reader.open('icon') as i:
smdh = SMDH.load(i)
except InvalidSMDHError:
print('ExeFS: Failed to load smdh')
else:
icon_small = BytesIO()
icon_large = BytesIO()
smdh.icon_small.save(icon_small, 'png')
smdh.icon_large.save(icon_large, 'png')
icon_small_size = icon_small.seek(0, 2)
icon_large_size = icon_large.seek(0, 2)
# these names are too long to be in the exefs, so special checks can be added for them
self.special_files['/icon_small.png'] = {'size': icon_small_size, 'io': icon_small}
self.special_files['/icon_large.png'] = {'size': icon_large_size, 'io': icon_large}
@_c.ensure_lower_path
def getattr(self, path, fh=None):
@ -69,11 +99,15 @@ class ExeFSMount(LoggingMixIn, Operations): @@ -69,11 +99,15 @@ class ExeFSMount(LoggingMixIn, Operations):
if path == '/':
st = {'st_mode': (S_IFDIR | 0o555), 'st_nlink': 2}
else:
try:
if path in self.files:
item = self.reader.entries[self.files[path]]
except KeyError:
size = item.size
elif path in self.special_files:
item = self.special_files[path]
size = item['size']
else:
raise FuseOSError(ENOENT)
st = {'st_mode': (S_IFREG | 0o444), 'st_size': item.size, 'st_nlink': 1}
st = {'st_mode': (S_IFREG | 0o444), 'st_size': size, 'st_nlink': 1}
return {**st, **self.g_stat, 'st_uid': uid, 'st_gid': gid}
def open(self, path, flags):
@ -84,20 +118,26 @@ class ExeFSMount(LoggingMixIn, Operations): @@ -84,20 +118,26 @@ class ExeFSMount(LoggingMixIn, Operations):
def readdir(self, path, fh):
yield from ('.', '..')
yield from (x[1:] for x in self.files)
yield from (x[1:] for x in self.special_files)
@_c.ensure_lower_path
def read(self, path, size, offset, fh):
try:
if path in self.files:
with self.reader.open(self.files[path]) as f:
f.seek(offset)
return f.read(size)
except (KeyError, ExeFSFileNotFoundError):
elif path in self.special_files:
with self.special_files_lock:
f = self.special_files[path]['io']
f.seek(offset)
return f.read(size)
else:
raise FuseOSError(ENOENT)
@_c.ensure_lower_path
def statfs(self, path):
return {'f_bsize': 4096, 'f_frsize': 4096, 'f_blocks': self.exefs_size // 4096, 'f_bavail': 0, 'f_bfree': 0,
'f_files': len(self.reader)}
'f_files': len(self.reader) + len(self.special_files)}
def main(prog: str = None, args: list = None):

5
ninfs/mount/sd.py

@ -11,6 +11,7 @@ Mounts SD contents under `/Nintendo 3DS`, creating a virtual filesystem with dec @@ -11,6 +11,7 @@ Mounts SD contents under `/Nintendo 3DS`, creating a virtual filesystem with dec
import logging
import os
from errno import EPERM, EACCES
from os.path import basename, dirname, isdir
from sys import exit, argv
from threading import Lock
from typing import TYPE_CHECKING
@ -37,7 +38,7 @@ class SDFilesystemMount(LoggingMixIn, Operations): @@ -37,7 +38,7 @@ class SDFilesystemMount(LoggingMixIn, Operations):
def fd_to_fileobj(self, path, mode, fd):
fh = open(fd, mode, buffering=0)
lock = Lock()
if not (os.path.basename(path).startswith('.') or 'nintendo dsiware' in path.lower()):
if not (basename(path).startswith('.') or 'nintendo dsiware' in path.lower() or dirname(path) == self.root):
fh_enc = self.crypto.create_ctr_io(Keyslot.SD, fh, self.path_to_iv(path))
fh_group = (fh_enc, fh, lock)
else:
@ -58,7 +59,7 @@ class SDFilesystemMount(LoggingMixIn, Operations): @@ -58,7 +59,7 @@ class SDFilesystemMount(LoggingMixIn, Operations):
self.crypto.setup_sd_key(movable)
self.root_dir = self.crypto.id0.hex()
if not os.path.isdir(sd_dir + '/' + self.root_dir):
if not isdir(sd_dir + '/' + self.root_dir):
exit(f'Could not find ID0 {self.root_dir} in the SD directory.')
print('ID0:', self.root_dir)

194
ninfs/mount/sdtitle.py

@ -0,0 +1,194 @@ @@ -0,0 +1,194 @@
# This file is a part of ninfs.
#
# Copyright (c) 2017-2021 Ian Burgwin
# This file is licensed under The MIT License (MIT).
# You can find the full license text in LICENSE.md in the root of this project.
"""
Mounts installed SD title contents, creating a virtual filesystem of decrypted contents (if encrypted).
"""
import logging
from errno import ENOENT
from glob import glob
from os.path import isfile, join
from stat import S_IFDIR, S_IFREG
from sys import argv
from typing import TYPE_CHECKING
from pyctr.crypto import load_seeddb
from pyctr.type.sdtitle import SDTitleReader, SDTitleSection
from . import _common as _c
# _common imports these from fusepy, and prints an error if it fails; this allows less duplicated code
from ._common import FUSE, FuseOSError, Operations, LoggingMixIn, fuse_get_context, get_time, load_custom_boot9, \
realpath
from .ncch import NCCHContainerMount
from .srl import SRLMount
if TYPE_CHECKING:
from os import DirEntry
from typing import Dict, Tuple, Union
class SDTitleContentsMount(LoggingMixIn, Operations):
fd = 0
total_size = 0
def __init__(self, reader: 'SDTitleReader', g_stat: dict):
self.dirs: Dict[str, Union[NCCHContainerMount, SRLMount]] = {}
self.files: Dict[str, Tuple[Union[int, SDTitleSection], int, int]] = {}
# get status change, modify, and file access times
self.g_stat = g_stat
self.reader = reader
def __del__(self, *args):
try:
self.reader.close()
except AttributeError:
pass
destroy = __del__
def init(self, path):
def add_file(name: str, section: 'Union[SDTitleSection, int]', added_offset: int = 0):
# added offset is used for a few things like meta icon and tmdchunks
if section >= 0:
size = self.reader.content_info[section].size
else:
with self.reader.open_raw_section(section) as f:
size = f.seek(0, 2)
self.files[name] = (section, added_offset, size - added_offset)
add_file('/tmd.bin', SDTitleSection.TitleMetadata)
add_file('/tmdchunks.bin', SDTitleSection.TitleMetadata, 0xB04)
for record in self.reader.content_info:
dirname = f'/{record.cindex:04x}.{record.id}'
is_srl = record.cindex == 0 and self.reader.tmd.title_id[3:5] == '48'
file_ext = 'nds' if is_srl else 'ncch'
filename = f'{dirname}.{file_ext}'
add_file(filename, record.cindex)
try:
if is_srl:
# special case for SRL contents
srl_fp = self.reader.open_raw_section(record.cindex)
self.dirs[dirname] = SRLMount(srl_fp, g_stat=self.g_stat)
else:
mount = NCCHContainerMount(self.reader.contents[record.cindex], g_stat=self.g_stat)
mount.init(path)
self.dirs[dirname] = mount
except Exception as e:
print(f'Failed to mount {filename}: {type(e).__name__}: {e}')
self.total_size += record.size
@_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][2], '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)
section = self.files[path]
with self.reader.open_raw_section(section[0]) as f:
f.seek(offset + section[1])
return f.read(size)
@_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_frsize': 4096, 'f_blocks': self.total_size // 4096, 'f_bavail': 0, 'f_bfree': 0,
'f_files': len(self.files)}
def main(prog: str = None, args: list = None):
from argparse import ArgumentParser
if args is None:
args = argv[1:]
parser = ArgumentParser(prog=prog, description='Mount Nintendo 3DS installed SD title contents.',
parents=(_c.default_argp, _c.ctrcrypto_argp, _c.seeddb_argp,
_c.main_args('content', 'tmd file or directory with SD title contents')))
a = parser.parse_args(args)
opts = dict(_c.parse_fuse_opts(a.o))
if a.do:
logging.basicConfig(level=logging.DEBUG, filename=a.do)
if isfile(a.content):
tmd_file = a.content
else:
tmd_file = None
tmds = glob(join(a.content, '*.tmd'))
if tmds:
# if there end up being multiple, this should use the first one that's found, which is probably the
# active one used by the system right now
tmd_file = tmds[0]
else:
exit(f'Could not find a tmd in {a.content}')
sdtitle_stat = get_time(a.content)
load_custom_boot9(a.boot9)
if a.seeddb:
load_seeddb(a.seeddb)
with SDTitleReader(tmd_file, dev=a.dev, seed=a.seed, case_insensitive=True) as r:
mount = SDTitleContentsMount(reader=r, g_stat=sdtitle_stat)
if _c.macos or _c.windows:
opts['fstypename'] = 'SDT'
if _c.macos:
display = r.tmd.title_id.upper()
try:
title = r.contents[0].exefs.icon.get_app_title()
display += f'; ' + r.contents[0].product_code
if title.short_desc != 'unknown':
display += '; ' + title.short_desc
except:
pass
opts['volname'] = f'SD Title Contents ({display})'
elif _c.windows:
# volume label can only be up to 32 chars
try:
title = r.contents[0].exefs.icon.get_app_title().short_desc
if len(title) > 21:
title = title[0:20] + '\u2026' # ellipsis
display = title
except:
display = r.tmd.title_id.upper()
opts['volname'] = f'SD Title ({display})'
FUSE(mount, a.mount_point, foreground=a.fg or a.d, ro=True, nothreads=True, debug=a.d,
fsname=realpath(tmd_file).replace(',', '_'), **opts)

6
ninfs/mountinfo.py

@ -49,6 +49,10 @@ types = { @@ -49,6 +49,10 @@ types = {
'name': 'SD Card Contents',
'info': '"Nintendo 3DS" from SD'
},
'sdtitle': {
'name': 'Installed SD Title Contents',
'info': '"*.tmd" and "*.app" files'
},
'srl': {
'name': 'Nintendo DS ROM image',
'info': '".nds", ".srl"'
@ -75,7 +79,7 @@ aliases = { @@ -75,7 +79,7 @@ aliases = {
}
categories = {
'Nintendo 3DS': ['cci', 'cdn', 'cia', 'exefs', 'nandctr', 'ncch', 'romfs', 'sd', 'threedsx'],
'Nintendo 3DS': ['cci', 'cdn', 'cia', 'exefs', 'nandctr', 'ncch', 'romfs', 'sd', 'sdtitle', 'threedsx'],
'Nintendo DS / DSi': ['nandtwl', 'srl'],
'Nintendo Switch': ['nandhac'],
'iQue Player': ['nandbb']

2
requirements.txt

@ -1,3 +1,3 @@ @@ -1,3 +1,3 @@
pyctr>=0.4,<0.5
pyctr>=0.5.1,<0.6
haccrypto==0.1.1
pycryptodomex>=3.9,<4

2
setup.py

@ -39,7 +39,7 @@ setup( @@ -39,7 +39,7 @@ setup(
'Programming Language :: Python :: 3.8',
'Programming Language :: Python :: 3.9',
],
install_requires=['pycryptodomex>=3.9,<4', 'pyctr>=0.4,<0.5', 'haccrypto==0.1.0'],
install_requires=['pycryptodomex>=3.9,<4', 'pyctr>=0.5.1,<0.6', 'haccrypto==0.1.1'],
python_requires='>=3.6.1',
# fusepy should be added here once the main repo has a new release with Windows support.
entry_points={'gui_scripts': ['ninfsw = ninfs.main:gui'],

Loading…
Cancel
Save