♻️ 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 readSolidpkg from './ssn/readSolidpkg';
|
||||
import readSsnFile from './ssn/readSsnFile';
|
||||
|
||||
(async () => {
|
||||
const buffer = await getSolidpkg('assets_swtor_de_de', -1, 0);
|
||||
console.log(buffer.length, buffer);
|
||||
|
||||
const output = readSolidpkg(buffer);
|
||||
console.log(output);
|
||||
const fileEntries = readSsnFile(buffer);
|
||||
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