mirror of
https://github.com/NationalSecurityAgency/ghidra.git
synced 2025-10-04 18:29:37 +02:00
GP-4625 - Modify dev PDB Dump script and add scripts to dump PDB mangled names; realign mangled name for complex type hierarchy
This commit is contained in:
parent
a29a59488a
commit
cb3a6ced93
7 changed files with 322 additions and 36 deletions
|
@ -0,0 +1,166 @@
|
||||||
|
/* ###
|
||||||
|
* IP: GHIDRA
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*/
|
||||||
|
// Dump PDB mangled symbol names for PDB developer use
|
||||||
|
//
|
||||||
|
//@category PDB
|
||||||
|
|
||||||
|
import java.io.*;
|
||||||
|
|
||||||
|
import docking.widgets.values.GValuesMap;
|
||||||
|
import ghidra.app.script.GhidraScript;
|
||||||
|
import ghidra.app.util.bin.format.pdb2.pdbreader.*;
|
||||||
|
import ghidra.app.util.bin.format.pdb2.pdbreader.symbol.*;
|
||||||
|
import ghidra.app.util.pdb.pdbapplicator.SymbolGroup;
|
||||||
|
import ghidra.features.base.values.GhidraValuesMap;
|
||||||
|
import ghidra.util.*;
|
||||||
|
import ghidra.util.exception.CancelledException;
|
||||||
|
|
||||||
|
public class PdbDeveloperDumpMangledSymbolNamesScript extends GhidraScript {
|
||||||
|
|
||||||
|
private static final String TITLE = "Dump PDB Mangled Symbol Names";
|
||||||
|
private static final String PDB_PROMPT = "Choose a PDB file";
|
||||||
|
private static final String OUTPUT_PROMPT = "Choose an output file";
|
||||||
|
|
||||||
|
private static boolean validatePdb(GValuesMap valueMap, StatusListener status) {
|
||||||
|
File file = valueMap.getFile(PDB_PROMPT);
|
||||||
|
if (file == null) {
|
||||||
|
status.setStatusText("PDB file must be selected.", MessageType.ERROR);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (!file.exists()) {
|
||||||
|
status.setStatusText(file.getAbsolutePath() + " is not a valid file.",
|
||||||
|
MessageType.ERROR);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
String fileName = file.getAbsolutePath();
|
||||||
|
if (!fileName.endsWith(".pdb") && !fileName.endsWith(".PDB")) {
|
||||||
|
status.setStatusText("Expected .pdb file extenstion (got '" + fileName + "').",
|
||||||
|
MessageType.ERROR);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
// We do not need to check the existence of an image base because we provide a default
|
||||||
|
// value
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static boolean validateOutput(GValuesMap valueMap, StatusListener status) {
|
||||||
|
File file = valueMap.getFile(OUTPUT_PROMPT);
|
||||||
|
// File will exist, as we supplied a default value
|
||||||
|
String fileName = file.getAbsolutePath();
|
||||||
|
if (fileName.endsWith(".pdb") || fileName.endsWith(".PDB")) {
|
||||||
|
status.setStatusText("Output file may not end with .pdb (got '" + fileName + "').",
|
||||||
|
MessageType.ERROR);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected void run() throws Exception {
|
||||||
|
|
||||||
|
GhidraValuesMap values = new GhidraValuesMap();
|
||||||
|
|
||||||
|
values.defineFile(PDB_PROMPT, null);
|
||||||
|
values.setValidator((valueMap, status) -> {
|
||||||
|
return validatePdb(valueMap, status);
|
||||||
|
});
|
||||||
|
values = askValues(TITLE, null, values);
|
||||||
|
File pdbFile = values.getFile(PDB_PROMPT);
|
||||||
|
String pdbFileName = pdbFile.getAbsolutePath();
|
||||||
|
|
||||||
|
// creating a default output and asking again; PDB file should retain its current value
|
||||||
|
// from above
|
||||||
|
String outputFileName = pdbFileName + ".MangledSymbolNames.txt";
|
||||||
|
values.defineFile(OUTPUT_PROMPT, new File(outputFileName));
|
||||||
|
values.setValidator((valueMap, status) -> {
|
||||||
|
return validatePdb(valueMap, status) && validateOutput(valueMap, status);
|
||||||
|
});
|
||||||
|
values = askValues(TITLE, null, values);
|
||||||
|
pdbFile = values.getFile(PDB_PROMPT); // might have changed
|
||||||
|
pdbFileName = pdbFile.getAbsolutePath(); // might have changed
|
||||||
|
File dumpFile = values.getFile(OUTPUT_PROMPT);
|
||||||
|
|
||||||
|
if (dumpFile.exists()) {
|
||||||
|
if (!askYesNo("Confirm Overwrite", "Overwrite file: " + dumpFile.getName())) {
|
||||||
|
Msg.info(this, "Operation canceled");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
String message = "Processing PDB Dump of: " + pdbFileName;
|
||||||
|
monitor.setMessage(message);
|
||||||
|
Msg.info(this, message);
|
||||||
|
try (AbstractPdb pdb = PdbParser.parse(pdbFile, new PdbReaderOptions(), monitor)) {
|
||||||
|
pdb.deserialize();
|
||||||
|
FileWriter fileWriter = new FileWriter(dumpFile);
|
||||||
|
BufferedWriter bufferedWriter = new BufferedWriter(fileWriter);
|
||||||
|
dumpMangledSymbolNames(pdb, bufferedWriter);
|
||||||
|
bufferedWriter.close();
|
||||||
|
}
|
||||||
|
catch (IOException ioe) {
|
||||||
|
Msg.info(this, ioe.getMessage());
|
||||||
|
popup(ioe.getMessage());
|
||||||
|
}
|
||||||
|
message = "Results located in: " + dumpFile.getAbsoluteFile();
|
||||||
|
monitor.setMessage(message);
|
||||||
|
Msg.info(this, message);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void dumpMangledSymbolNames(AbstractPdb pdb, Writer myWriter)
|
||||||
|
throws PdbException, CancelledException, IOException {
|
||||||
|
|
||||||
|
PdbDebugInfo debugInfo = pdb.getDebugInfo();
|
||||||
|
if (debugInfo == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
int num = debugInfo.getNumModules();
|
||||||
|
for (int moduleNumber = 0; moduleNumber <= num; moduleNumber++) {
|
||||||
|
monitor.checkCancelled();
|
||||||
|
SymbolGroup symbolGroup = new SymbolGroup(pdb, moduleNumber);
|
||||||
|
MsSymbolIterator iter = symbolGroup.getSymbolIterator();
|
||||||
|
dumpIteratedMangledSymbolNames(iter, myWriter);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void dumpIteratedMangledSymbolNames(MsSymbolIterator iter, Writer myWriter)
|
||||||
|
throws CancelledException, IOException {
|
||||||
|
while (iter.hasNext()) {
|
||||||
|
monitor.checkCancelled();
|
||||||
|
AbstractMsSymbol symbol = iter.next();
|
||||||
|
if (symbol == null) {
|
||||||
|
throw new AssertionError("null symbol");
|
||||||
|
}
|
||||||
|
if (!(symbol instanceof NameMsSymbol s)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!(symbol instanceof AbstractDataMsSymbol ||
|
||||||
|
symbol instanceof AbstractProcedureMsSymbol ||
|
||||||
|
symbol instanceof AbstractUserDefinedTypeMsSymbol ||
|
||||||
|
symbol instanceof AbstractPublicMsSymbol)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
String name = s.getName();
|
||||||
|
if (name.contains("?") || name.contains("@") || name.contains(".")) {
|
||||||
|
myWriter.write(name);
|
||||||
|
myWriter.write("\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
|
@ -0,0 +1,141 @@
|
||||||
|
/* ###
|
||||||
|
* IP: GHIDRA
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*/
|
||||||
|
// Dump PDB mangled type names for PDB developer use
|
||||||
|
//
|
||||||
|
//@category PDB
|
||||||
|
|
||||||
|
import java.io.*;
|
||||||
|
|
||||||
|
import docking.widgets.values.GValuesMap;
|
||||||
|
import ghidra.app.script.GhidraScript;
|
||||||
|
import ghidra.app.util.bin.format.pdb2.pdbreader.*;
|
||||||
|
import ghidra.app.util.bin.format.pdb2.pdbreader.type.AbstractComplexMsType;
|
||||||
|
import ghidra.app.util.bin.format.pdb2.pdbreader.type.AbstractMsType;
|
||||||
|
import ghidra.features.base.values.GhidraValuesMap;
|
||||||
|
import ghidra.util.*;
|
||||||
|
import ghidra.util.exception.CancelledException;
|
||||||
|
|
||||||
|
public class PdbDeveloperDumpMangledTypeNamesScript extends GhidraScript {
|
||||||
|
|
||||||
|
private static final String TITLE = "Dump PDB Mangled Type Names";
|
||||||
|
private static final String PDB_PROMPT = "Choose a PDB file";
|
||||||
|
private static final String OUTPUT_PROMPT = "Choose an output file";
|
||||||
|
|
||||||
|
private static boolean validatePdb(GValuesMap valueMap, StatusListener status) {
|
||||||
|
File file = valueMap.getFile(PDB_PROMPT);
|
||||||
|
if (file == null) {
|
||||||
|
status.setStatusText("PDB file must be selected.", MessageType.ERROR);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (!file.exists()) {
|
||||||
|
status.setStatusText(file.getAbsolutePath() + " is not a valid file.",
|
||||||
|
MessageType.ERROR);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
String fileName = file.getAbsolutePath();
|
||||||
|
if (!fileName.endsWith(".pdb") && !fileName.endsWith(".PDB")) {
|
||||||
|
status.setStatusText("Expected .pdb file extenstion (got '" + fileName + "').",
|
||||||
|
MessageType.ERROR);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
// We do not need to check the existence of an image base because we provide a default
|
||||||
|
// value
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static boolean validateOutput(GValuesMap valueMap, StatusListener status) {
|
||||||
|
File file = valueMap.getFile(OUTPUT_PROMPT);
|
||||||
|
// File will exist, as we supplied a default value
|
||||||
|
String fileName = file.getAbsolutePath();
|
||||||
|
if (fileName.endsWith(".pdb") || fileName.endsWith(".PDB")) {
|
||||||
|
status.setStatusText("Output file may not end with .pdb (got '" + fileName + "').",
|
||||||
|
MessageType.ERROR);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected void run() throws Exception {
|
||||||
|
|
||||||
|
GhidraValuesMap values = new GhidraValuesMap();
|
||||||
|
|
||||||
|
values.defineFile(PDB_PROMPT, null);
|
||||||
|
values.setValidator((valueMap, status) -> {
|
||||||
|
return validatePdb(valueMap, status);
|
||||||
|
});
|
||||||
|
values = askValues(TITLE, null, values);
|
||||||
|
File pdbFile = values.getFile(PDB_PROMPT);
|
||||||
|
String pdbFileName = pdbFile.getAbsolutePath();
|
||||||
|
|
||||||
|
// creating a default output and asking again; PDB file should retain its current value
|
||||||
|
// from above
|
||||||
|
String outputFileName = pdbFileName + ".MangledTypeNames.txt";
|
||||||
|
values.defineFile(OUTPUT_PROMPT, new File(outputFileName));
|
||||||
|
values.setValidator((valueMap, status) -> {
|
||||||
|
return validatePdb(valueMap, status) && validateOutput(valueMap, status);
|
||||||
|
});
|
||||||
|
values = askValues(TITLE, null, values);
|
||||||
|
pdbFile = values.getFile(PDB_PROMPT); // might have changed
|
||||||
|
pdbFileName = pdbFile.getAbsolutePath(); // might have changed
|
||||||
|
File dumpFile = values.getFile(OUTPUT_PROMPT);
|
||||||
|
|
||||||
|
if (dumpFile.exists()) {
|
||||||
|
if (!askYesNo("Confirm Overwrite", "Overwrite file: " + dumpFile.getName())) {
|
||||||
|
Msg.info(this, "Operation canceled");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
String message = "Processing PDB Dump of: " + pdbFileName;
|
||||||
|
Msg.info(this, message);
|
||||||
|
try (AbstractPdb pdb = PdbParser.parse(pdbFile, new PdbReaderOptions(), monitor)) {
|
||||||
|
pdb.deserialize();
|
||||||
|
FileWriter fileWriter = new FileWriter(dumpFile);
|
||||||
|
BufferedWriter bufferedWriter = new BufferedWriter(fileWriter);
|
||||||
|
dumpMangledTypeNames(pdb, bufferedWriter);
|
||||||
|
bufferedWriter.close();
|
||||||
|
}
|
||||||
|
catch (IOException ioe) {
|
||||||
|
Msg.info(this, ioe.getMessage());
|
||||||
|
popup(ioe.getMessage());
|
||||||
|
}
|
||||||
|
message = "Results located in: " + dumpFile.getAbsoluteFile();
|
||||||
|
Msg.info(this, message);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void dumpMangledTypeNames(AbstractPdb pdb, Writer myWriter)
|
||||||
|
throws CancelledException, IOException {
|
||||||
|
TypeProgramInterface tpi = pdb.getTypeProgramInterface();
|
||||||
|
if (tpi == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for (int indexNumber = tpi.getTypeIndexMin(); indexNumber < tpi
|
||||||
|
.getTypeIndexMaxExclusive(); indexNumber++) {
|
||||||
|
monitor.checkCancelled();
|
||||||
|
RecordNumber recordNumber = RecordNumber.typeRecordNumber(indexNumber);
|
||||||
|
AbstractMsType msType = pdb.getTypeRecord(recordNumber);
|
||||||
|
if (msType instanceof AbstractComplexMsType type) {
|
||||||
|
String mangled = type.getMangledName();
|
||||||
|
if (mangled != null) {
|
||||||
|
myWriter.write(mangled);
|
||||||
|
myWriter.write("\n");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
|
@ -27,6 +27,7 @@ import ghidra.util.*;
|
||||||
|
|
||||||
public class PdbDeveloperDumpScript extends GhidraScript {
|
public class PdbDeveloperDumpScript extends GhidraScript {
|
||||||
|
|
||||||
|
private static final String TITLE = "PDB Dump";
|
||||||
private static final String PDB_PROMPT = "Choose a PDB file";
|
private static final String PDB_PROMPT = "Choose a PDB file";
|
||||||
private static final String OUTPUT_PROMPT = "Choose an output file";
|
private static final String OUTPUT_PROMPT = "Choose an output file";
|
||||||
|
|
||||||
|
@ -73,7 +74,7 @@ public class PdbDeveloperDumpScript extends GhidraScript {
|
||||||
values.setValidator((valueMap, status) -> {
|
values.setValidator((valueMap, status) -> {
|
||||||
return validatePdb(valueMap, status);
|
return validatePdb(valueMap, status);
|
||||||
});
|
});
|
||||||
values = askValues("Enter Values", null, values);
|
values = askValues(TITLE, null, values);
|
||||||
File pdbFile = values.getFile(PDB_PROMPT);
|
File pdbFile = values.getFile(PDB_PROMPT);
|
||||||
String pdbFileName = pdbFile.getAbsolutePath();
|
String pdbFileName = pdbFile.getAbsolutePath();
|
||||||
|
|
||||||
|
@ -82,12 +83,9 @@ public class PdbDeveloperDumpScript extends GhidraScript {
|
||||||
String outputFileName = pdbFileName + ".txt";
|
String outputFileName = pdbFileName + ".txt";
|
||||||
values.defineFile(OUTPUT_PROMPT, new File(outputFileName));
|
values.defineFile(OUTPUT_PROMPT, new File(outputFileName));
|
||||||
values.setValidator((valueMap, status) -> {
|
values.setValidator((valueMap, status) -> {
|
||||||
if (!validatePdb(valueMap, status)) {
|
return validatePdb(valueMap, status) && validateOutput(valueMap, status);
|
||||||
return false;
|
|
||||||
}
|
|
||||||
return validateOutput(valueMap, status);
|
|
||||||
});
|
});
|
||||||
values = askValues("Enter Values", null, values);
|
values = askValues(TITLE, null, values);
|
||||||
pdbFile = values.getFile(PDB_PROMPT); // might have changed
|
pdbFile = values.getFile(PDB_PROMPT); // might have changed
|
||||||
pdbFileName = pdbFile.getAbsolutePath(); // might have changed
|
pdbFileName = pdbFile.getAbsolutePath(); // might have changed
|
||||||
File dumpFile = values.getFile(OUTPUT_PROMPT);
|
File dumpFile = values.getFile(OUTPUT_PROMPT);
|
||||||
|
|
|
@ -32,6 +32,8 @@ public abstract class AbstractComplexMsType extends AbstractMsType {
|
||||||
protected RecordNumber fieldDescriptorListRecordNumber;
|
protected RecordNumber fieldDescriptorListRecordNumber;
|
||||||
protected MsProperty property;
|
protected MsProperty property;
|
||||||
protected String name;
|
protected String name;
|
||||||
|
// Used by MsType and 19MsType; maybe by StMsType; not by 16MsType
|
||||||
|
protected String mangledName;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Constructor for this type.
|
* Constructor for this type.
|
||||||
|
@ -100,6 +102,14 @@ public abstract class AbstractComplexMsType extends AbstractMsType {
|
||||||
return name;
|
return name;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the mangled name within this complex type
|
||||||
|
* @return Mangled name
|
||||||
|
*/
|
||||||
|
public String getMangledName() {
|
||||||
|
return mangledName;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns the type name of this complex type.
|
* Returns the type name of this complex type.
|
||||||
* @return Type of the complex type.
|
* @return Type of the complex type.
|
||||||
|
|
|
@ -31,7 +31,6 @@ public abstract class AbstractCompositeMsType extends AbstractComplexMsType {
|
||||||
protected RecordNumber vShapeTableRecordNumber; // Not used by union.
|
protected RecordNumber vShapeTableRecordNumber; // Not used by union.
|
||||||
//TODO: has more... guessing below
|
//TODO: has more... guessing below
|
||||||
protected BigInteger size;
|
protected BigInteger size;
|
||||||
protected String mangledName; // Used by MsType (not used by 16MsType or StMsType?)
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Constructor for this type.
|
* Constructor for this type.
|
||||||
|
@ -69,14 +68,6 @@ public abstract class AbstractCompositeMsType extends AbstractComplexMsType {
|
||||||
this.vShapeTableRecordNumber = vShapeTableRecordNumber;
|
this.vShapeTableRecordNumber = vShapeTableRecordNumber;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns the mangled name within this composite.
|
|
||||||
* @return Mangled name.
|
|
||||||
*/
|
|
||||||
public String getMangledName() {
|
|
||||||
return mangledName;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns the record number of the derived-from list of types.
|
* Returns the record number of the derived-from list of types.
|
||||||
* @return Record number of the derived-from list of types.
|
* @return Record number of the derived-from list of types.
|
||||||
|
|
|
@ -37,8 +37,6 @@ public class Enum19MsType extends AbstractEnumMsType {
|
||||||
|
|
||||||
public static final int PDB_ID = 0x160b;
|
public static final int PDB_ID = 0x160b;
|
||||||
|
|
||||||
protected String mangledName;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Constructor for this type.
|
* Constructor for this type.
|
||||||
* @param pdb {@link AbstractPdb} to which this type belongs.
|
* @param pdb {@link AbstractPdb} to which this type belongs.
|
||||||
|
@ -72,12 +70,4 @@ public class Enum19MsType extends AbstractEnumMsType {
|
||||||
return PDB_ID;
|
return PDB_ID;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns the mangled name field
|
|
||||||
* @return Mangled name.
|
|
||||||
*/
|
|
||||||
public String getMangledName() {
|
|
||||||
return mangledName;
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -27,8 +27,6 @@ public class EnumMsType extends AbstractEnumMsType {
|
||||||
|
|
||||||
public static final int PDB_ID = 0x1507;
|
public static final int PDB_ID = 0x1507;
|
||||||
|
|
||||||
protected String mangledName;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Constructor for this type.
|
* Constructor for this type.
|
||||||
* @param pdb {@link AbstractPdb} to which this type belongs.
|
* @param pdb {@link AbstractPdb} to which this type belongs.
|
||||||
|
@ -62,12 +60,4 @@ public class EnumMsType extends AbstractEnumMsType {
|
||||||
return PDB_ID;
|
return PDB_ID;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns the mangled name field
|
|
||||||
* @return Mangled name.
|
|
||||||
*/
|
|
||||||
public String getMangledName() {
|
|
||||||
return mangledName;
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue