♻️ readSsnFile now reads all file entries
This commit is contained in:
parent
1a623aa42d
commit
9899514578
4 changed files with 202 additions and 147 deletions
|
@ -1,10 +1,10 @@
|
||||||
import getSolidpkg from './ssn/getSolidpkg';
|
import getSolidpkg from './ssn/getSolidpkg';
|
||||||
import readSolidpkg from './ssn/readSolidpkg';
|
import readSsnFile from './ssn/readSsnFile';
|
||||||
|
|
||||||
(async () => {
|
(async () => {
|
||||||
const buffer = await getSolidpkg('assets_swtor_de_de', -1, 0);
|
const buffer = await getSolidpkg('assets_swtor_de_de', -1, 0);
|
||||||
console.log(buffer.length, buffer);
|
console.log(buffer.length, buffer);
|
||||||
|
|
||||||
const output = readSolidpkg(buffer);
|
const fileEntries = readSsnFile(buffer);
|
||||||
console.log(output);
|
console.log(fileEntries);
|
||||||
})();
|
})();
|
||||||
|
|
29
src/interfaces/ISsnFileEntry.ts
Normal file
29
src/interfaces/ISsnFileEntry.ts
Normal file
|
@ -0,0 +1,29 @@
|
||||||
|
enum SsnDiffType {
|
||||||
|
/** We included the original file contents, not just the differences, because file is new or it's in a format that's not suitable for diffing. */
|
||||||
|
NewFile = 0,
|
||||||
|
/** File is not included in the .zip archive because it has been deleted. */
|
||||||
|
Deleted = 1,
|
||||||
|
/** File has changed and we include the differences, encoded in vcdiff/xdelta3 */
|
||||||
|
Changed = 2,
|
||||||
|
/** File is not included in the .zip archive because it hasn't changed */
|
||||||
|
Unchanged = 3,
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ISsnFileEntry {
|
||||||
|
/** CRC-32 checksum of this file. */
|
||||||
|
crc: number;
|
||||||
|
/** If we only store the differences, the size of the destination file. */
|
||||||
|
diffDestLength: number;
|
||||||
|
/** If we only store the differences, the size of the source file. */
|
||||||
|
diffSourceLength: number;
|
||||||
|
/** Whether this file was changed or not, or whether it was newly added or deleted. */
|
||||||
|
diffType: SsnDiffType;
|
||||||
|
/** The date and time when this file was last modified. */
|
||||||
|
lastMod: Date;
|
||||||
|
/** File name */
|
||||||
|
name: string;
|
||||||
|
/** Uncompressed size */
|
||||||
|
size: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ISsnFileEntry;
|
|
@ -1,144 +0,0 @@
|
||||||
/**
|
|
||||||
* The file format used by Solid State Networks is based on the .zip format.
|
|
||||||
* Check the .ZIP File Format Specification <https://pkware.cachefly.net/webdocs/casestudies/APPNOTE.TXT>.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { TextDecoder } from 'util';
|
|
||||||
import modifyPassword from './modifyPassword';
|
|
||||||
|
|
||||||
const SIGNATURE_END_OF_CENTRAL_DIR = 0x06054b50;
|
|
||||||
const SIGNATURE_CENTRAL_DIR = 0x02014b50;
|
|
||||||
const COMPRESSION_DEFLATE = 8;
|
|
||||||
|
|
||||||
const Decoder = new TextDecoder('utf-8');
|
|
||||||
|
|
||||||
export default function readSolidpkg(buffer: Buffer) {
|
|
||||||
const arrayBuffer = buffer.buffer;
|
|
||||||
const dv = new DataView(arrayBuffer);
|
|
||||||
|
|
||||||
//--------------- READ END OF CENTRAL DIR ---------------
|
|
||||||
|
|
||||||
//Go to end of file
|
|
||||||
let pos = buffer.length - 22; //end of central dir is at least 22 bytes long
|
|
||||||
|
|
||||||
//Find end of central dir
|
|
||||||
while (pos >= 0 && dv.getUint32(pos, true) !== SIGNATURE_END_OF_CENTRAL_DIR) {
|
|
||||||
pos -= 1;
|
|
||||||
}
|
|
||||||
if (pos < 0) {
|
|
||||||
throw new Error('Could not find end of central dir.');
|
|
||||||
}
|
|
||||||
pos += 4;
|
|
||||||
|
|
||||||
/** skip 6 bytes:
|
|
||||||
* number of this disk,
|
|
||||||
* number of the disk with the start of the central directory
|
|
||||||
* total number of entries in the central directory on this disk
|
|
||||||
*/
|
|
||||||
pos += 6;
|
|
||||||
|
|
||||||
/** Total number of entries in the central directory */
|
|
||||||
const numEntries = dv.getUint16(pos, true); pos += 2;
|
|
||||||
/** Size of the central directory */
|
|
||||||
const centralDirSize = dv.getUint32(pos, true); pos += 4;
|
|
||||||
/** Offset of start of central directory with respect to the starting disk number */
|
|
||||||
const centralDirOffset = dv.getUint32(pos, true); pos += 4;
|
|
||||||
//ignore .ZIP file comment length
|
|
||||||
|
|
||||||
if (numEntries !== 1) {
|
|
||||||
throw new Error(`Expected numEntries == 1 in end of central dir but it was "${numEntries}"`);
|
|
||||||
}
|
|
||||||
if (centralDirSize < 46 * numEntries) {
|
|
||||||
throw new Error('centralDirSize was smaller than expected in end of central dir.');
|
|
||||||
}
|
|
||||||
if (pos - centralDirSize < 0) {
|
|
||||||
throw new Error(`Central dir points before file start (0x${(pos - centralDirSize).toString(16)})`);
|
|
||||||
}
|
|
||||||
|
|
||||||
//--------------- READ CENTRAL DIRECTORY ---------------
|
|
||||||
|
|
||||||
//Go to start of central dir
|
|
||||||
pos -= 20 + centralDirSize;
|
|
||||||
|
|
||||||
{
|
|
||||||
const signature = dv.getUint32(pos, true); pos += 4;
|
|
||||||
if (signature !== SIGNATURE_CENTRAL_DIR) {
|
|
||||||
throw new Error(`Expected central dir signature but found "0x${signature.toString(16)}"`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pos += 4; //skip version made by and version needed to extract
|
|
||||||
|
|
||||||
/** The general purpose bit flag stores whether the file is encrypted or not.
|
|
||||||
* Most files are encrypted but there are some exceptions: assets_swtor_test_main_248to249.solidpkg,
|
|
||||||
* assets_swtor_test_en_us_270to271.solidpkg, and retailclient_publictest_246to247.solidpkg are not encrypted.
|
|
||||||
*/
|
|
||||||
const bitFlag = dv.getUint16(pos, true); pos += 2;
|
|
||||||
|
|
||||||
/** Compression method, always set to 8 = DEFLATE. */
|
|
||||||
const compression = dv.getUint16(pos, true); pos += 2;
|
|
||||||
if (compression !== COMPRESSION_DEFLATE) {
|
|
||||||
throw new Error(`File is not using DEFLATE compression but "${compression}"`);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** last mod file time */
|
|
||||||
const lastMod1 = dv.getUint16(pos, true); pos += 2;
|
|
||||||
/** last mod file date */
|
|
||||||
const lastMod2 = dv.getUint16(pos, true); pos += 2;
|
|
||||||
/** crc-32 */
|
|
||||||
const fileCrc = dv.getUint32(pos, true); pos += 4;
|
|
||||||
/** compressed size */
|
|
||||||
const comprSize = dv.getUint32(pos, true); pos += 4;
|
|
||||||
/** uncompressed size */
|
|
||||||
const uncomprSize = dv.getUint32(pos, true); pos += 4;
|
|
||||||
/** file name length */
|
|
||||||
const fileNameLength = dv.getUint16(pos, true); pos += 2;
|
|
||||||
/** extra field length */
|
|
||||||
const extraFieldLength = dv.getUint16(pos, true); pos += 2;
|
|
||||||
/** file comment length */
|
|
||||||
const fileCommentLength = dv.getUint16(pos, true); pos += 2;
|
|
||||||
pos += 8; //skip disk number start, internal file attributes and external file attributes
|
|
||||||
/** relative offset of local header */
|
|
||||||
const relOffset = dv.getUint32(pos, true); pos += 4;
|
|
||||||
/** file name (variable size) */
|
|
||||||
const fileName = Decoder.decode(new DataView(arrayBuffer, pos, fileNameLength)); pos += fileNameLength;
|
|
||||||
if (fileName !== 'metafile.solid') {
|
|
||||||
throw new Error(`Expected file name to be "metafile.solid" but it was "${fileName}".`);
|
|
||||||
}
|
|
||||||
|
|
||||||
//read password from extra field
|
|
||||||
let encodedPassword: Uint8Array | undefined;
|
|
||||||
const extraFieldEnd = pos + extraFieldLength;
|
|
||||||
while (pos + 4 <= extraFieldEnd) {
|
|
||||||
const fieldId = dv.getUint16(pos, true); pos += 2;
|
|
||||||
const fieldLength = dv.getUint16(pos, true); pos += 2;
|
|
||||||
switch (fieldId) {
|
|
||||||
case 0x8810: { //password
|
|
||||||
if (fieldLength > 120) {
|
|
||||||
throw new Error(`Password is too long, it should be 120 characters at most but it is ${fieldLength} characters long.`);
|
|
||||||
}
|
|
||||||
const passwordLength = fieldLength;
|
|
||||||
encodedPassword = new Uint8Array(arrayBuffer, pos, fieldLength);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
case 0x80AE: //unknown, some kind of hash or checksum, ignore it
|
|
||||||
break;
|
|
||||||
default:
|
|
||||||
//unknown field, ignore it
|
|
||||||
}
|
|
||||||
pos += fieldLength;
|
|
||||||
}
|
|
||||||
if (typeof encodedPassword === 'undefined') {
|
|
||||||
throw new Error('Could not find password in extra field.');
|
|
||||||
}
|
|
||||||
|
|
||||||
const decodedPassword = modifyPassword(encodedPassword);
|
|
||||||
|
|
||||||
//TODO: read encrypted + compressed file
|
|
||||||
//pos = start of central dir
|
|
||||||
//
|
|
||||||
//fseek(fp, pos - centralDirOffset, SEEK_SET);
|
|
||||||
//...
|
|
||||||
|
|
||||||
return { lastMod1, lastMod2, fileCrc, comprSize, uncomprSize, fileNameLength, extraFieldLength, fileCommentLength, relOffset, fileName, encodedPassword, decodedPassword };
|
|
||||||
}
|
|
170
src/ssn/readSsnFile.ts
Normal file
170
src/ssn/readSsnFile.ts
Normal file
|
@ -0,0 +1,170 @@
|
||||||
|
/**
|
||||||
|
* The file format used by Solid State Networks is based on the .zip format.
|
||||||
|
* Check the .ZIP File Format Specification <https://pkware.cachefly.net/webdocs/casestudies/APPNOTE.TXT>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { TextDecoder } from 'util';
|
||||||
|
import ISsnFileEntry from '../interfaces/ISsnFileEntry';
|
||||||
|
import modifyPassword from './modifyPassword';
|
||||||
|
|
||||||
|
const SIGNATURE_END_OF_CENTRAL_DIR = 0x06054b50;
|
||||||
|
const SIGNATURE_CENTRAL_DIR = 0x02014b50;
|
||||||
|
const COMPRESSION_DEFLATE = 8;
|
||||||
|
|
||||||
|
const Decoder = new TextDecoder('utf-8');
|
||||||
|
|
||||||
|
export default function readSsnFile(buffer: Buffer): ISsnFileEntry[] {
|
||||||
|
const arrayBuffer = buffer.buffer;
|
||||||
|
const dv = new DataView(arrayBuffer);
|
||||||
|
|
||||||
|
const fileEntries: ISsnFileEntry[] = [];
|
||||||
|
|
||||||
|
//--------------- READ END OF CENTRAL DIR ---------------
|
||||||
|
|
||||||
|
//Go to end of file
|
||||||
|
let pos = buffer.length - 22; //end of central dir is at least 22 bytes long
|
||||||
|
|
||||||
|
//Find end of central dir
|
||||||
|
while (pos >= 0 && dv.getUint32(pos, true) !== SIGNATURE_END_OF_CENTRAL_DIR) {
|
||||||
|
pos -= 1;
|
||||||
|
}
|
||||||
|
if (pos < 0) {
|
||||||
|
throw new Error('Could not find end of central dir.');
|
||||||
|
}
|
||||||
|
pos += 4;
|
||||||
|
|
||||||
|
/** skip 6 bytes:
|
||||||
|
* number of this disk,
|
||||||
|
* number of the disk with the start of the central directory
|
||||||
|
* total number of entries in the central directory on this disk
|
||||||
|
*/
|
||||||
|
pos += 6;
|
||||||
|
|
||||||
|
/** Total number of entries in the central directory */
|
||||||
|
const numEntries = dv.getUint16(pos, true); pos += 2;
|
||||||
|
/** Size of the central directory */
|
||||||
|
const centralDirSize = dv.getUint32(pos, true); pos += 4;
|
||||||
|
/** Offset of start of central directory with respect to the starting disk number */
|
||||||
|
const centralDirOffset = dv.getUint32(pos, true); pos += 4;
|
||||||
|
//ignore .ZIP file comment length
|
||||||
|
|
||||||
|
if (centralDirSize < 46 * numEntries) {
|
||||||
|
throw new Error('centralDirSize was smaller than expected in end of central dir.');
|
||||||
|
}
|
||||||
|
if (pos - centralDirSize < 0) {
|
||||||
|
throw new Error(`Central dir points before file start (0x${(pos - centralDirSize).toString(16)})`);
|
||||||
|
}
|
||||||
|
|
||||||
|
//--------------- READ CENTRAL DIRECTORY ---------------
|
||||||
|
|
||||||
|
//Go to start of central dir
|
||||||
|
pos -= 20 + centralDirSize;
|
||||||
|
|
||||||
|
for (let i = 0; i < numEntries; i += 1) {
|
||||||
|
{
|
||||||
|
const signature = dv.getUint32(pos, true); pos += 4;
|
||||||
|
if (signature !== SIGNATURE_CENTRAL_DIR) {
|
||||||
|
throw new Error(`Expected central dir signature but found "0x${signature.toString(16)}"`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pos += 4; //skip version made by and version needed to extract
|
||||||
|
|
||||||
|
/** The general purpose bit flag stores whether the file is encrypted or not.
|
||||||
|
* Most files are encrypted but there are some exceptions: assets_swtor_test_main_248to249.solidpkg,
|
||||||
|
* assets_swtor_test_en_us_270to271.solidpkg, and retailclient_publictest_246to247.solidpkg are not encrypted.
|
||||||
|
*/
|
||||||
|
const bitFlag = dv.getUint16(pos, true); pos += 2;
|
||||||
|
|
||||||
|
/** Compression method, always set to 8 = DEFLATE. */
|
||||||
|
const compression = dv.getUint16(pos, true); pos += 2;
|
||||||
|
if (compression !== COMPRESSION_DEFLATE) {
|
||||||
|
throw new Error(`File is not using DEFLATE compression but "${compression}"`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** last mod file time */
|
||||||
|
const lastModTime = dv.getUint16(pos, true); pos += 2;
|
||||||
|
/** last mod file date */
|
||||||
|
const lastModDate = dv.getUint16(pos, true); pos += 2;
|
||||||
|
/** crc-32 */
|
||||||
|
const fileCrc = dv.getUint32(pos, true); pos += 4;
|
||||||
|
/** compressed size */
|
||||||
|
const comprSize = dv.getUint32(pos, true); pos += 4;
|
||||||
|
/** uncompressed size */
|
||||||
|
const uncomprSize = dv.getUint32(pos, true); pos += 4;
|
||||||
|
/** file name length */
|
||||||
|
const fileNameLength = dv.getUint16(pos, true); pos += 2;
|
||||||
|
/** extra field length */
|
||||||
|
const extraFieldLength = dv.getUint16(pos, true); pos += 2;
|
||||||
|
/** file comment length */
|
||||||
|
const fileCommentLength = dv.getUint16(pos, true); pos += 2;
|
||||||
|
pos += 8; //skip disk number start, internal file attributes and external file attributes
|
||||||
|
/** relative offset of local header */
|
||||||
|
const relOffset = dv.getUint32(pos, true); pos += 4;
|
||||||
|
/** file name (variable size) */
|
||||||
|
const fileName = Decoder.decode(new DataView(arrayBuffer, pos, fileNameLength)); pos += fileNameLength;
|
||||||
|
if (fileName !== 'metafile.solid') {
|
||||||
|
throw new Error(`Expected file name to be "metafile.solid" but it was "${fileName}".`);
|
||||||
|
}
|
||||||
|
|
||||||
|
//read password from extra field
|
||||||
|
const extraFieldEnd = pos + extraFieldLength;
|
||||||
|
let encodedPassword: Uint8Array | undefined;
|
||||||
|
let diffType: ISsnFileEntry['diffType'] = -1;
|
||||||
|
let diffSourceLength = -1;
|
||||||
|
let diffDestLength = -1;
|
||||||
|
while (pos + 4 <= extraFieldEnd) {
|
||||||
|
const fieldId = dv.getUint16(pos, true); pos += 2;
|
||||||
|
const fieldLength = dv.getUint16(pos, true); pos += 2;
|
||||||
|
switch (fieldId) {
|
||||||
|
case 0x8810: { //password
|
||||||
|
if (fieldLength > 120) {
|
||||||
|
throw new Error(`Password is too long, it should be 120 characters at most but it is ${fieldLength} characters long.`);
|
||||||
|
}
|
||||||
|
const passwordLength = fieldLength;
|
||||||
|
encodedPassword = new Uint8Array(arrayBuffer, pos, fieldLength);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 0x80AE: //diff type
|
||||||
|
//type: 0 = no diff (usually a new file), 1 = file deleted, 2 = vcdiff/xdelta3, 3=unchanged
|
||||||
|
diffType = dv.getUint32(pos, true); pos += 4;
|
||||||
|
//size of source file, is actually a uint64 but we only read 32-bits
|
||||||
|
diffSourceLength = dv.getUint32(pos, true); pos += 8;
|
||||||
|
//size of destination file, is actually a uint64 but we only read 32-bits
|
||||||
|
diffDestLength = dv.getUint32(pos + 12, true); pos += 8;
|
||||||
|
//skip 20 bytes: hash of old file
|
||||||
|
//skip 20 bytes: hash of new file
|
||||||
|
pos += 40;
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
//unknown field, ignore it
|
||||||
|
}
|
||||||
|
pos += fieldLength;
|
||||||
|
}
|
||||||
|
if (typeof encodedPassword === 'undefined') {// && (bitFlag & 1) !== 0 TODO: check if file is encrypted, otherwise no password needed
|
||||||
|
throw new Error('File was encrypted but could not find password in extra field.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const decodedPassword = modifyPassword(encodedPassword);
|
||||||
|
|
||||||
|
pos += fileCommentLength;
|
||||||
|
|
||||||
|
fileEntries.push({
|
||||||
|
crc: fileCrc,
|
||||||
|
diffDestLength,
|
||||||
|
diffSourceLength,
|
||||||
|
diffType,
|
||||||
|
lastMod: new Date(1980 + (lastModDate >>> 9), (lastModDate & 0x1E0) >>> 5, lastModDate & 0x1F, lastModTime >>> 11, (lastModTime & 0x7E0) >>> 5, (lastModTime & 0x1F) * 2),
|
||||||
|
name: fileName,
|
||||||
|
size: uncomprSize,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
//TODO: read encrypted + compressed file
|
||||||
|
//pos = start of central dir
|
||||||
|
//
|
||||||
|
//fseek(fp, pos - centralDirOffset, SEEK_SET);
|
||||||
|
//...
|
||||||
|
|
||||||
|
return fileEntries;
|
||||||
|
}
|
Loading…
Reference in a new issue