mirror of
https://github.com/DarkMatterCore/nxdumptool.git
synced 2024-11-29 13:42:11 +00:00
NPDM context.
This commit is contained in:
parent
dcbedbf13a
commit
2066b11d5a
3 changed files with 308 additions and 9 deletions
|
@ -220,7 +220,7 @@ bool cnmtInitializeContext(ContentMetaContext *out, NcaContext *nca_ctx)
|
|||
/* Safety check: verify raw CNMT size. */
|
||||
if (cur_offset != out->raw_data_size)
|
||||
{
|
||||
LOGFILE("Raw CNMT size mismatch! (0x%X != 0x%X).", cur_offset, out->raw_data_size);
|
||||
LOGFILE("Raw CNMT size mismatch! (0x%lX != 0x%lX).", cur_offset, out->raw_data_size);
|
||||
goto end;
|
||||
}
|
||||
|
||||
|
|
238
source/npdm.c
238
source/npdm.c
|
@ -21,3 +21,241 @@
|
|||
#include "utils.h"
|
||||
#include "npdm.h"
|
||||
|
||||
bool npdmInitializeContext(NpdmContext *out, PartitionFileSystemContext *pfs_ctx)
|
||||
{
|
||||
NcaContext *nca_ctx = NULL;
|
||||
u64 cur_offset = 0;
|
||||
bool success = false;
|
||||
|
||||
if (!out || !pfs_ctx || !pfs_ctx->nca_fs_ctx || !(nca_ctx = (NcaContext*)pfs_ctx->nca_fs_ctx->nca_ctx) || nca_ctx->content_type != NcmContentType_Program || !pfs_ctx->offset || !pfs_ctx->size || \
|
||||
!pfs_ctx->is_exefs || pfs_ctx->header_size <= sizeof(PartitionFileSystemHeader) || !pfs_ctx->header)
|
||||
{
|
||||
LOGFILE("Invalid parameters!");
|
||||
return false;
|
||||
}
|
||||
|
||||
/* Free output context beforehand. */
|
||||
npdmFreeContext(out);
|
||||
|
||||
/* Get 'main.npdm' file entry. */
|
||||
out->pfs_ctx = pfs_ctx;
|
||||
if (!(out->pfs_entry = pfsGetEntryByName(out->pfs_ctx, "main.npdm")))
|
||||
{
|
||||
LOGFILE("'main.npdm' entry unavailable in ExeFS!");
|
||||
goto end;
|
||||
}
|
||||
|
||||
//LOGFILE("Found 'main.npdm' entry in Program NCA \"%s\".", nca_ctx->content_id_str);
|
||||
|
||||
/* Check raw NPDM size. */
|
||||
if (!out->pfs_entry->size)
|
||||
{
|
||||
LOGFILE("Invalid raw NPDM size!");
|
||||
goto end;
|
||||
}
|
||||
|
||||
/* Allocate memory for the raw NPDM data. */
|
||||
out->raw_data_size = out->pfs_entry->size;
|
||||
if (!(out->raw_data = malloc(out->raw_data_size)))
|
||||
{
|
||||
LOGFILE("Failed to allocate memory for the raw NPDM data!");
|
||||
goto end;
|
||||
}
|
||||
|
||||
/* Read raw NPDM data into memory buffer. */
|
||||
if (!pfsReadEntryData(out->pfs_ctx, out->pfs_entry, out->raw_data, out->raw_data_size, 0))
|
||||
{
|
||||
LOGFILE("Failed to read raw NPDM data!");
|
||||
goto end;
|
||||
}
|
||||
|
||||
/* Calculate SHA-256 checksum for the whole raw NPDM. */
|
||||
sha256CalculateHash(out->raw_data_hash, out->raw_data, out->raw_data_size);
|
||||
|
||||
/* Verify meta header. */
|
||||
out->meta_header = (NpdmMetaHeader*)out->raw_data;
|
||||
cur_offset += sizeof(NpdmMetaHeader);
|
||||
|
||||
if (__builtin_bswap32(out->meta_header->magic) != NPDM_META_MAGIC)
|
||||
{
|
||||
LOGFILE("Invalid meta header magic word! (0x%08X != 0x%08X).", __builtin_bswap32(out->meta_header->magic), __builtin_bswap32(NPDM_META_MAGIC));
|
||||
goto end;
|
||||
}
|
||||
|
||||
if (!out->meta_header->flags.is_64bit_instruction && (out->meta_header->flags.process_address_space == NpdmProcessAddressSpace_AddressSpace64BitOld || \
|
||||
out->meta_header->flags.process_address_space == NpdmProcessAddressSpace_AddressSpace64Bit))
|
||||
{
|
||||
LOGFILE("Invalid meta header flags! (0x%02X).", *((u8*)&(out->meta_header->flags)));
|
||||
goto end;
|
||||
}
|
||||
|
||||
if (out->meta_header->main_thread_priority > NPDM_MAIN_THREAD_MAX_PRIORITY)
|
||||
{
|
||||
LOGFILE("Invalid main thread priority! (0x%02X).", out->meta_header->main_thread_priority);
|
||||
goto end;
|
||||
}
|
||||
|
||||
if (out->meta_header->main_thread_core_number > NPDM_MAIN_THREAD_MAX_CORE_NUMBER)
|
||||
{
|
||||
LOGFILE("Invalid main thread core number! (%u).", out->meta_header->main_thread_core_number);
|
||||
goto end;
|
||||
}
|
||||
|
||||
if (out->meta_header->system_resource_size > NPDM_SYSTEM_RESOURCE_MAX_SIZE)
|
||||
{
|
||||
LOGFILE("Invalid system resource size! (0x%08X).", out->meta_header->system_resource_size);
|
||||
goto end;
|
||||
}
|
||||
|
||||
if (!IS_ALIGNED(out->meta_header->main_thread_stack_size, NPDM_MAIN_THREAD_STACK_SIZE_ALIGNMENT))
|
||||
{
|
||||
LOGFILE("Invalid main thread stack size! (0x%08X).", out->meta_header->main_thread_stack_size);
|
||||
goto end;
|
||||
}
|
||||
|
||||
if (out->meta_header->aci_offset < sizeof(NpdmMetaHeader) || out->meta_header->aci_size < sizeof(NpdmAciHeader) || (out->meta_header->aci_offset + out->meta_header->aci_size) > out->raw_data_size)
|
||||
{
|
||||
LOGFILE("Invalid ACI0 offset/size! (0x%08X, 0x%08X).", out->meta_header->aci_offset, out->meta_header->aci_size);
|
||||
goto end;
|
||||
}
|
||||
|
||||
if (out->meta_header->acid_offset < sizeof(NpdmMetaHeader) || out->meta_header->acid_size < sizeof(NpdmAcidHeader) || (out->meta_header->acid_offset + out->meta_header->acid_size) > out->raw_data_size)
|
||||
{
|
||||
LOGFILE("Invalid ACID offset/size! (0x%08X, 0x%08X).", out->meta_header->acid_offset, out->meta_header->acid_size);
|
||||
goto end;
|
||||
}
|
||||
|
||||
if (out->meta_header->aci_offset == out->meta_header->acid_offset || \
|
||||
(out->meta_header->aci_offset > out->meta_header->acid_offset && out->meta_header->aci_offset < (out->meta_header->acid_offset + out->meta_header->acid_size)) || \
|
||||
(out->meta_header->acid_offset > out->meta_header->aci_offset && out->meta_header->acid_offset < (out->meta_header->aci_offset + out->meta_header->aci_size)))
|
||||
{
|
||||
LOGFILE("ACI0/ACID sections overlap! (0x%08X, 0x%08X | 0x%08X, 0x%08X).", out->meta_header->aci_offset, out->meta_header->aci_size, out->meta_header->acid_offset, out->meta_header->acid_size);
|
||||
goto end;
|
||||
}
|
||||
|
||||
/* Verify ACID section. */
|
||||
out->acid_header = (NpdmAcidHeader*)(out->raw_data + out->meta_header->acid_offset);
|
||||
cur_offset += out->meta_header->acid_size;
|
||||
|
||||
if (__builtin_bswap32(out->acid_header->magic) != NPDM_ACID_MAGIC)
|
||||
{
|
||||
LOGFILE("Invalid ACID header magic word! (0x%08X != 0x%08X).", __builtin_bswap32(out->acid_header->magic), __builtin_bswap32(NPDM_ACID_MAGIC));
|
||||
goto end;
|
||||
}
|
||||
|
||||
if (out->acid_header->size != (out->meta_header->acid_size - sizeof(out->acid_header->signature)))
|
||||
{
|
||||
LOGFILE("Invalid ACID header size! (0x%08X).", out->acid_header->size);
|
||||
goto end;
|
||||
}
|
||||
|
||||
if (out->acid_header->program_id_min > out->acid_header->program_id_max)
|
||||
{
|
||||
LOGFILE("Invalid ACID program ID range! (%016lX - %016lX).", out->acid_header->program_id_min, out->acid_header->program_id_max);
|
||||
goto end;
|
||||
}
|
||||
|
||||
if (out->acid_header->fs_access_control_offset < sizeof(NpdmAcidHeader) || out->acid_header->fs_access_control_size < sizeof(NpdmAcidFsAccessControlDescriptor) || \
|
||||
(out->acid_header->fs_access_control_offset + out->acid_header->fs_access_control_size) > out->meta_header->acid_size)
|
||||
{
|
||||
LOGFILE("Invalid ACID FsAccessControl offset/size! (0x%08X, 0x%08X).", out->acid_header->fs_access_control_offset, out->acid_header->fs_access_control_size);
|
||||
goto end;
|
||||
}
|
||||
|
||||
out->acid_fac_descriptor = (NpdmAcidFsAccessControlDescriptor*)(out->raw_data + out->meta_header->acid_offset + out->acid_header->fs_access_control_offset);
|
||||
|
||||
if (out->acid_header->srv_access_control_size)
|
||||
{
|
||||
if (out->acid_header->srv_access_control_offset < sizeof(NpdmAcidHeader) || \
|
||||
(out->acid_header->srv_access_control_offset + out->acid_header->srv_access_control_size) > out->meta_header->acid_size)
|
||||
{
|
||||
LOGFILE("Invalid ACID SrvAccessControl offset/size! (0x%08X, 0x%08X).", out->acid_header->srv_access_control_offset, out->acid_header->srv_access_control_size);
|
||||
goto end;
|
||||
}
|
||||
|
||||
out->acid_sac_descriptor = (NpdmSrvAccessControlDescriptorEntry*)(out->raw_data + out->meta_header->acid_offset + out->acid_header->srv_access_control_offset);
|
||||
}
|
||||
|
||||
if (out->acid_header->kernel_capability_size)
|
||||
{
|
||||
if (!IS_ALIGNED(out->acid_header->kernel_capability_size, sizeof(NpdmKernelCapabilityDescriptorEntry)) || \
|
||||
out->acid_header->kernel_capability_offset < sizeof(NpdmAcidHeader) || \
|
||||
(out->acid_header->kernel_capability_offset + out->acid_header->kernel_capability_size) > out->meta_header->acid_size)
|
||||
{
|
||||
LOGFILE("Invalid ACID KernelCapability offset/size! (0x%08X, 0x%08X).", out->acid_header->kernel_capability_offset, out->acid_header->kernel_capability_size);
|
||||
goto end;
|
||||
}
|
||||
|
||||
out->acid_kc_descriptor = (NpdmKernelCapabilityDescriptorEntry*)(out->raw_data + out->meta_header->acid_offset + out->acid_header->kernel_capability_offset);
|
||||
}
|
||||
|
||||
/* Verify ACI0 section. */
|
||||
out->aci_header = (NpdmAciHeader*)(out->raw_data + out->meta_header->aci_offset);
|
||||
cur_offset += out->meta_header->aci_size;
|
||||
|
||||
if (__builtin_bswap32(out->aci_header->magic) != NPDM_ACI0_MAGIC)
|
||||
{
|
||||
LOGFILE("Invalid ACI0 header magic word! (0x%08X != 0x%08X).", __builtin_bswap32(out->aci_header->magic), __builtin_bswap32(NPDM_ACI0_MAGIC));
|
||||
goto end;
|
||||
}
|
||||
|
||||
if (out->aci_header->program_id != nca_ctx->header.program_id)
|
||||
{
|
||||
LOGFILE("ACI0 program ID mismatch! (%016lX != %016lX).", out->aci_header->program_id, nca_ctx->header.program_id);
|
||||
goto end;
|
||||
}
|
||||
|
||||
if (out->aci_header->program_id < out->acid_header->program_id_min || out->aci_header->program_id > out->acid_header->program_id_max)
|
||||
{
|
||||
LOGFILE("ACI0 program ID out of ACID program ID range! (%016lX, %016lX - %016lX).", out->aci_header->program_id, out->acid_header->program_id_min, out->acid_header->program_id_max);
|
||||
goto end;
|
||||
}
|
||||
|
||||
if (out->aci_header->fs_access_control_offset < sizeof(NpdmAciHeader) || out->aci_header->fs_access_control_size < sizeof(NpdmAciFsAccessControlDescriptor) || \
|
||||
(out->aci_header->fs_access_control_offset + out->aci_header->fs_access_control_size) > out->meta_header->aci_size)
|
||||
{
|
||||
LOGFILE("Invalid ACI0 FsAccessControl offset/size! (0x%08X, 0x%08X).", out->aci_header->fs_access_control_offset, out->aci_header->fs_access_control_size);
|
||||
goto end;
|
||||
}
|
||||
|
||||
out->aci_fac_descriptor = (NpdmAciFsAccessControlDescriptor*)(out->raw_data + out->meta_header->aci_offset + out->aci_header->fs_access_control_offset);
|
||||
|
||||
if (out->aci_header->srv_access_control_size)
|
||||
{
|
||||
if (out->aci_header->srv_access_control_offset < sizeof(NpdmAciHeader) || \
|
||||
(out->aci_header->srv_access_control_offset + out->aci_header->srv_access_control_size) > out->meta_header->aci_size)
|
||||
{
|
||||
LOGFILE("Invalid ACI0 SrvAccessControl offset/size! (0x%08X, 0x%08X).", out->aci_header->srv_access_control_offset, out->aci_header->srv_access_control_size);
|
||||
goto end;
|
||||
}
|
||||
|
||||
out->aci_sac_descriptor = (NpdmSrvAccessControlDescriptorEntry*)(out->raw_data + out->meta_header->aci_offset + out->aci_header->srv_access_control_offset);
|
||||
}
|
||||
|
||||
if (out->aci_header->kernel_capability_size)
|
||||
{
|
||||
if (!IS_ALIGNED(out->aci_header->kernel_capability_size, sizeof(NpdmKernelCapabilityDescriptorEntry)) || \
|
||||
out->aci_header->kernel_capability_offset < sizeof(NpdmAciHeader) || \
|
||||
(out->aci_header->kernel_capability_offset + out->aci_header->kernel_capability_size) > out->meta_header->aci_size)
|
||||
{
|
||||
LOGFILE("Invalid ACI0 KernelCapability offset/size! (0x%08X, 0x%08X).", out->aci_header->kernel_capability_offset, out->aci_header->kernel_capability_size);
|
||||
goto end;
|
||||
}
|
||||
|
||||
out->aci_kc_descriptor = (NpdmKernelCapabilityDescriptorEntry*)(out->raw_data + out->meta_header->aci_offset + out->aci_header->kernel_capability_offset);
|
||||
}
|
||||
|
||||
/* Safety check: verify raw NPDM size. */
|
||||
if (out->raw_data_size < cur_offset)
|
||||
{
|
||||
LOGFILE("Invalid raw NPDM size! (0x%lX < 0x%lX).", out->raw_data_size, cur_offset);
|
||||
goto end;
|
||||
}
|
||||
|
||||
success = true;
|
||||
|
||||
end:
|
||||
if (!success) npdmFreeContext(out);
|
||||
|
||||
return success;
|
||||
}
|
||||
|
|
|
@ -23,9 +23,16 @@
|
|||
#ifndef __NPDM_H__
|
||||
#define __NPDM_H__
|
||||
|
||||
#define NPDM_META_MAGIC 0x4D455441 /* "META". */
|
||||
#define NPDM_ACID_MAGIC 0x41434944 /* "ACID". */
|
||||
#define NPDM_ACI0_MAGIC 0x41434930 /* "ACI0". */
|
||||
#include "pfs.h"
|
||||
|
||||
#define NPDM_META_MAGIC 0x4D455441 /* "META". */
|
||||
#define NPDM_ACID_MAGIC 0x41434944 /* "ACID". */
|
||||
#define NPDM_ACI0_MAGIC 0x41434930 /* "ACI0". */
|
||||
|
||||
#define NPDM_MAIN_THREAD_MAX_PRIORITY 0x3F
|
||||
#define NPDM_MAIN_THREAD_MAX_CORE_NUMBER 3
|
||||
#define NPDM_SYSTEM_RESOURCE_MAX_SIZE 0x1FE00000
|
||||
#define NPDM_MAIN_THREAD_STACK_SIZE_ALIGNMENT 0x1000
|
||||
|
||||
typedef enum {
|
||||
NpdmProcessAddressSpace_AddressSpace32Bit = 0,
|
||||
|
@ -49,12 +56,12 @@ typedef struct {
|
|||
u8 reserved_1[0x7];
|
||||
NpdmMetaFlags flags;
|
||||
u8 reserved_2;
|
||||
u8 main_thread_priority; ///< Ranges from 0x00 to 0x3F.
|
||||
u8 main_thread_core_number; ///< CPU ID. Ranges from 0 to 3.
|
||||
u8 main_thread_priority; ///< Must not exceed NPDM_MAIN_THREAD_MAX_PRIORITY.
|
||||
u8 main_thread_core_number; ///< Must not exceed NPDM_MAIN_THREAD_MAX_CORE_NUMBER.
|
||||
u8 reserved_3[0x4];
|
||||
u32 system_resource_size; ///< Must not exceed 0x1FE00000.
|
||||
u32 system_resource_size; ///< Must not exceed NPDM_SYSTEM_RESOURCE_MAX_SIZE.
|
||||
VersionType1 version;
|
||||
u32 main_thread_stack_size; ///< Must be aligned to 0x1000.
|
||||
u32 main_thread_stack_size; ///< Must be aligned to NPDM_MAIN_THREAD_STACK_SIZE_ALIGNMENT.
|
||||
char name[0x10]; ///< Usually set to "Application".
|
||||
char product_code[0x10]; ///< Usually zeroed out.
|
||||
u8 reserved_4[0x30];
|
||||
|
@ -526,7 +533,61 @@ typedef struct {
|
|||
u32 value;
|
||||
} NpdmKernelCapabilityDescriptorEntry;
|
||||
|
||||
/// Returns a value that can be compared to values from the NpdmKernelCapabilityEntryValue enum.
|
||||
typedef struct {
|
||||
PartitionFileSystemContext *pfs_ctx; ///< PartitionFileSystemContext for the Program NCA FS section #0, which is where the NPDM is stored.
|
||||
PartitionFileSystemEntry *pfs_entry; ///< PartitionFileSystemEntry for the NPDM in the Program NCA FS section #0. Used to generate a NcaHierarchicalSha256Patch if needed.
|
||||
NcaHierarchicalSha256Patch nca_patch; ///< NcaHierarchicalSha256Patch generated if NPDM modifications are needed. Used to seamlessly replace Program NCA data while writing it.
|
||||
///< Bear in mind that generating a patch modifies the NCA context.
|
||||
u8 *raw_data; ///< Pointer to a dynamically allocated buffer that holds the raw NPDM.
|
||||
u64 raw_data_size; ///< Raw NPDM size. Kept here for convenience - this is part of 'pfs_entry'.
|
||||
u8 raw_data_hash[SHA256_HASH_SIZE]; ///< SHA-256 checksum calculated over the whole raw NPDM. Used to determine if NcaHierarchicalSha256Patch generation is truly needed.
|
||||
NpdmMetaHeader *meta_header; ///< Pointer to the NpdmMetaHeader within 'raw_data'.
|
||||
NpdmAcidHeader *acid_header; ///< Pointer to the NpdmAcidHeader within 'raw_data'.
|
||||
NpdmAcidFsAccessControlDescriptor *acid_fac_descriptor; ///< Pointer to the NpdmAcidFsAccessControlDescriptor within the NPDM ACID section.
|
||||
NpdmSrvAccessControlDescriptorEntry *acid_sac_descriptor; ///< Pointer to the first NpdmSrvAccessControlDescriptorEntry within the NPDM ACID section, if available.
|
||||
NpdmKernelCapabilityDescriptorEntry *acid_kc_descriptor; ///< Pointer to the first NpdmKernelCapabilityDescriptorEntry within the NPDM ACID section, if available.
|
||||
NpdmAciHeader *aci_header; ///< Pointer to the NpdmAciHeader within 'raw_data'.
|
||||
NpdmAciFsAccessControlDescriptor *aci_fac_descriptor; ///< Pointer to the NpdmAciFsAccessControlDescriptor within the NPDM ACI0 section.
|
||||
NpdmSrvAccessControlDescriptorEntry *aci_sac_descriptor; ///< Pointer to the first NpdmSrvAccessControlDescriptorEntry within the NPDM ACI0 section, if available.
|
||||
NpdmKernelCapabilityDescriptorEntry *aci_kc_descriptor; ///< Pointer to the first NpdmKernelCapabilityDescriptorEntry within the NPDM ACI0 section, if available.
|
||||
} NpdmContext;
|
||||
|
||||
/// Initializes a NpdmContext using a previously initialized PartitionFileSystemContext (which must belong to the ExeFS from a Program NCA).
|
||||
bool npdmInitializeContext(NpdmContext *out, PartitionFileSystemContext *pfs_ctx);
|
||||
|
||||
/// Helper inline functions.
|
||||
|
||||
NX_INLINE void npdmFreeContext(NpdmContext *npdm_ctx)
|
||||
{
|
||||
if (!npdm_ctx) return;
|
||||
pfsFreeEntryPatch(&(npdm_ctx->nca_patch));
|
||||
if (npdm_ctx->raw_data) free(npdm_ctx->raw_data);
|
||||
memset(npdm_ctx, 0, sizeof(NpdmContext));
|
||||
}
|
||||
|
||||
NX_INLINE bool npdmIsValidContext(NpdmContext *npdm_ctx)
|
||||
{
|
||||
return (npdm_ctx && npdm_ctx->pfs_ctx && npdm_ctx->pfs_entry && npdm_ctx->raw_data && npdm_ctx->raw_data_size && npdm_ctx->meta_header && npdm_ctx->acid_header && npdm_ctx->acid_fac_descriptor && \
|
||||
((npdm_ctx->acid_header->srv_access_control_size && npdm_ctx->acid_sac_descriptor) || (!npdm_ctx->acid_header->srv_access_control_size && !npdm_ctx->acid_sac_descriptor)) && \
|
||||
((npdm_ctx->acid_header->kernel_capability_size && npdm_ctx->acid_kc_descriptor) || (!npdm_ctx->acid_header->kernel_capability_size && !npdm_ctx->acid_kc_descriptor)) && \
|
||||
npdm_ctx->aci_header && npdm_ctx->aci_fac_descriptor && \
|
||||
((npdm_ctx->aci_header->srv_access_control_size && npdm_ctx->aci_sac_descriptor) || (!npdm_ctx->aci_header->srv_access_control_size && !npdm_ctx->aci_sac_descriptor)) && \
|
||||
((npdm_ctx->aci_header->kernel_capability_size && npdm_ctx->aci_kc_descriptor) || (!npdm_ctx->aci_header->kernel_capability_size && !npdm_ctx->aci_kc_descriptor)));
|
||||
}
|
||||
|
||||
NX_INLINE bool npdmIsNcaPatchRequired(NpdmContext *npdm_ctx)
|
||||
{
|
||||
if (!npdmIsValidContext(npdm_ctx)) return false;
|
||||
u8 tmp_hash[SHA256_HASH_SIZE] = {0};
|
||||
sha256CalculateHash(tmp_hash, npdm_ctx->raw_data, npdm_ctx->raw_data_size);
|
||||
return (memcmp(tmp_hash, npdm_ctx->raw_data_hash, SHA256_HASH_SIZE) != 0);
|
||||
}
|
||||
|
||||
NX_INLINE bool npdmGenerateNcaPatch(NpdmContext *npdm_ctx)
|
||||
{
|
||||
return (npdmIsValidContext(npdm_ctx) && pfsGenerateEntryPatch(npdm_ctx->pfs_ctx, npdm_ctx->pfs_entry, npdm_ctx->raw_data, npdm_ctx->raw_data_size, 0, &(npdm_ctx->nca_patch)));
|
||||
}
|
||||
|
||||
NX_INLINE u32 npdmGetKernelCapabilityDescriptorEntryValue(NpdmKernelCapabilityDescriptorEntry *entry)
|
||||
{
|
||||
return (entry ? (((entry->value + 1) & ~entry->value) - 1) : 0);
|
||||
|
|
Loading…
Reference in a new issue