Η βιβλιοθήκη HotXLS Excel Library για Delphi και C++Builder διαβάζει και γράφει το δοχείο Compound File Binary πίσω από κάθε παλαιό αρχείο .xls σε καθαρή Object Pascal. Η κλάση TlxCompoundFile υλοποιεί απευθείας τη διάταξη [MS-CFB] έκδοσης 3 πάνω σε ένα TStream — κεφαλίδα, DIFAT, αλυσίδες FAT, MiniFAT και δέντρο καταλόγου — χωρίς ole32.dll και χωρίς COM IStorage πουθενά στο μονοπάτι
Αυτό ακούγεται σαν υδραυλική εγκατάσταση, και για είκοσι χρόνια ήταν υδραυλική εγκατάσταση που ανήκε σε κάποιον άλλο. Κάθε βάση κώδικα Delphi που άγγιζε ένα αρχείο .xls έφτανε στο StgOpenStorage, έπαιρνε πίσω ένα IStorage, και έβγαζε το stream Workbook από μέσα του. Τρεις γραμμές, δούλευε μια χαρά, κανείς δεν το ξανασκεφτόταν — μέχρι την ημέρα που ο ίδιος κώδικας έπρεπε να τρέξει κάπου που δεν υπήρχαν Windows
Γιατί το StgOpenStorage σταματά να δουλεύει σε έναν server;
Το COM structured-storage API αποτυγχάνει ακριβώς στα σχήματα ανάπτυξης όπου ζει ο σύγχρονος κώδικας Delphi, για λόγους που δεν έχουν καμία σχέση με τη μορφή αρχείου. Το StgOpenStorage είναι ένα σημείο εισόδου Win32 στο ole32.dll: θέλει μια διαδρομή σε σύστημα αρχείων, θέλει το COM να έχει αρχικοποιηθεί στο νήμα κλήσης, και θέλει να βρίσκεται σε Windows. Η απαίτηση διαδρομής πονάει πρώτη, γιατί ένα REST endpoint που λαμβάνει ένα ανεβασμένο βιβλίο εργασίας έχει τα bytes σε buffer, όχι στον δίσκο — οπότε γράφεις τον buffer σε προσωρινό αρχείο, το ανοίγεις, το διαβάζεις πίσω, το διαγράφεις, και τώρα κατέχεις έναν κύκλο ζωής προσωρινού αρχείου για να τον κάνεις λάθος υπό φόρτο. Το ILockBytes είναι η τεκμηριωμένη διέξοδος, αλλά η καλωδίωση μιας προσαρμοσμένης υλοποίησης πάνω σε ένα TMemoryStream είναι περισσότερο COM interop απ' όσο θέλουν οι περισσότερες ομάδες. Η απαίτηση αρχικοποίησης δαγκώνει δεύτερη, συνήθως σε ένα νήμα εργάτη υπηρεσίας όπου κανείς δεν κάλεσε CoInitialize, και η απαίτηση πλατφόρμας τερματίζει τη συζήτηση τη στιγμή που ο στόχος είναι Linux υπό FPC, μια εικόνα container, ή macOS. Το HotXLS επομένως διατηρεί το κλασικό μονοπάτι lxOLE χτισμένο πάνω στο StgOpenStorage ως προεπιλογή, αφού είναι δοκιμασμένο στη μάχη και οι υπάρχοντες καλούντες δεν θα έπρεπε να χρειαστεί να αλλάξουν· το TlxCompoundFile είναι η προαιρετική εναλλακτική για όλους τους υπόλοιπους
Τι λένε πραγματικά η κεφαλίδα και οι αλυσίδες FAT
Τα πρώτα 512 bytes ενός compound file απαντούν σε κάθε δομικό ερώτημα που χρειάζεσαι πριν διαβάσεις έστω ένα byte payload. Το [MS-CFB] §2.2 καθορίζει την υπογραφή κεφαλίδας στη θέση 0 ως τα οκτώ bytes D0 CF 11 E0 A1 B1 1A E1, και το lxIsCompoundStream ελέγχει ακριβώς αυτό, επαναφέροντας τη θέση του stream μετά ώστε ένας καλών να μπορεί να «μυρίσει» χωρίς να διαταράξει τίποτα. Τέσσερα ακόμα πεδία καθορίζουν τη γεωμετρία: η σειρά bytes στο 0x1C πρέπει να είναι 0xFFFE, που λειτουργεί και ως φθηνός δεύτερος έλεγχος υπογραφής· η μετατόπιση τομέα στο 0x1E δίνει το μέγεθος τομέα ως 1 shl SectorShift, οπότε η έκδοση 3 χρησιμοποιεί μετατόπιση 9 για τομείς 512 bytes και η έκδοση 4 μετατόπιση 12 για 4096· η μετατόπιση mini τομέα στο 0x20 είναι 6, κάνοντας τους mini τομείς 64 bytes· και το όριο mini stream στο 0x38 είναι 4096. Η αριθμητική διευθύνσεων που ακολουθεί είναι το πιο συνηθισμένο σημείο για να κάνεις λάθος. Ο τομέας 0 ξεκινά αμέσως μετά την κεφαλίδα, οπότε ο τομέας N ξεκινά στη θέση byte 512 + N * SectorSize — πρόσεξε το κυριολεκτικό 512, όχι SectorSize. Σε ένα αρχείο έκδοσης 3 τα δύο είναι πανομοιότυπα και το bug κρύβεται για πάντα· σε ένα αρχείο έκδοσης 4 διαβάζει σιωπηρά τον λάθος τομέα, γι' αυτό το HotXLS το κρατά σε μία συνάρτηση, την SidToOffset
Ένα compound file είναι ένα σύστημα αρχείων FAT μέσα σε ένα αρχείο, οπότε η ανάγνωσή του σημαίνει τη διάτρεξη συνδεδεμένων λιστών από IDs τομέων όπου το FAT[n] κρατά το ID που ακολουθεί τον τομέα n. Τρεις φρουροί τερματίζουν ή σχολιάζουν μια αλυσίδα — το ENDOFCHAIN, το FATSECT για έναν τομέα που ανήκει στο ίδιο το FAT, και το DIFSECT για έναν τομέα DIFAT — και και τα τρία διαβάζονται ως αρνητικοί προσημασμένοι ακέραιοι 32-bit, κάτι που κρατά τις συνθήκες βρόχου απλές. Η εύρεση του FAT χρειάζεται μία ακόμα έμμεση αναφορά: το DIFAT είναι ο πίνακας από IDs τομέων που λέει πού βρίσκονται οι τομείς FAT, και οι πρώτες 109 καταχωρήσεις του κάθονται στην κεφαλίδα στη θέση 0x4C. Το TlxCompoundFile διατρέχει αυτές τις 109, σταματά στην πρώτη αρνητική καταχώρηση, και συνενώνει κάθε τομέα FAT σε έναν επίπεδο πίνακα Integer. Αυτό είναι 109 τομείς FAT με 128 καταχωρήσεις ο καθένας σε τομέα 512 bytes, άρα 13.952 προσπελάσιμοι τομείς, άρα περίπου 6,8 MiB δοχείου πριν το DIFAT πρέπει να ξεχειλίσει στη δική του αλυσίδα
Ο δεύτερος πίνακας εκχώρησης υπάρχει επειδή τομείς 512 bytes σπαταλούν το μεγαλύτερο μέρος του χώρου τους σε μικρά streams. Κάθε stream κάτω από το όριο των 4096 bytes δεν αποθηκεύεται καθόλου σε τομείς: ζει μέσα στο mini stream, το ίδιο ένα συνηθισμένο stream κρεμασμένο από την ριζική καταχώρηση καταλόγου, υποδιαιρεμένο σε mini τομείς 64 bytes και αλυσιδωτό μέσω ενός παράλληλου MiniFAT με ρίζα στη θέση κεφαλίδας 0x3C. Άνοιξε ένα πραγματικό .xls και το stream Workbook κάθεται στο κανονικό FAT ενώ τα streams πληροφοριών σύνοψης κάθονται κάτω στον χώρο mini-τομέα, γι' αυτό μια υλοποίηση που καλύπτει μόνο το μονοπάτι FAT φαίνεται να δουλεύει σωστά μέχρι τη στιγμή που χρειάζεται μεταδεδομένα εγγράφου. Ο κατάλογος είναι η τρίτη δομή και αυτή που κάνει το δοχείο πλοηγήσιμο: κάθε καταχώρηση είναι ακριβώς 128 bytes, τέσσερις ανά τομέα 512 bytes, φέροντας ένα όνομα UTF-16 στα πρώτα 64 bytes, το μήκος του σε bytes στο 0x40, τον τύπο αντικειμένου στο 0x42 (1 = storage, 2 = stream, 5 = root), συνδέσμους δέντρου στα 0x44, 0x48 και 0x4C, τον αρχικό τομέα στο 0x74 και το μέγεθος stream 32-bit στο 0x78. Αυτό το μήκος ονόματος μετρά bytes συμπεριλαμβανομένου του τερματικού null, οπότε ο αριθμός χαρακτήρων είναι NameLen div 2 - 1, και το να το κάνεις λάθος κατά ένα είναι το πώς καταλήγεις με ένα stream ονόματι Workboo
Εξαγωγή ενός stream Workbook από buffer μνήμης
Η TlxCompoundFile.OpenStream κρύβει όλα τα παραπάνω πίσω από μία κλήση που παίρνει ένα όνομα stream και επιστρέφει ένα TlxCfbStream κρατώντας τα πλήρως υλοποιημένα bytes. Η όλη ακολουθία — μύρισμα, φόρτωση, εξαγωγή — τρέχει πάνω σε ένα TBytesStream χωρίς τίποτα να αγγίζει ποτέ τον δίσκο
uses
Classes, SysUtils, lxCompoundFile;
function ExtractBiffPayload(const Blob: TBytes): TBytes;
var
Src: TBytesStream;
Cfb: TlxCompoundFile;
Wb: TlxCfbStream;
begin
SetLength(Result, 0);
Src:= TBytesStream.Create(Blob);
try
if not lxIsCompoundStream(Src) then
Exit; // not a CFB container at all
Cfb:= TlxCompoundFile.Create;
try
Cfb.LoadFromStream(Src); // header, FAT, directory, MiniFAT
Wb:= Cfb.OpenStream('Workbook'); // BIFF8
if Wb = nil then
Wb:= Cfb.OpenStream('Book'); // BIFF5 / BIFF7
if Wb <> nil then
try
Result:= Wb.Data;
finally
Wb.Free;
end;
finally
Cfb.Free;
end;
finally
Src.Free;
end;
end;
Αξίζει να αναφερθούν δύο λεπτομέρειες εκεί. Η LoadFromStream παίρνει μια σημαία AOwnsStream με προεπιλογή False, οπότε ο καλών κρατά την ευθύνη για το stream πηγής — σκόπιμα, γιατί η συνηθισμένη περίπτωση είναι ένα stream που η εφαρμογή ήδη κατέχει. Και η OpenStream επιστρέφει ένα TlxCfbStream που κατέχει το δικό του αντίγραφο των bytes, εκτεθειμένο μέσω των Data, Size, Read, Seek και CopyTo. Αυτό το αντίγραφο είναι πραγματικό κόστος σε ένα μεγάλο βιβλίο εργασίας, και είναι το τίμιο τίμημα ενός σχεδιασμού όπου το επιστρεφόμενο αντικείμενο παραμένει έγκυρο αφού απελευθερωθεί το δοχείο. Όταν ένα βιβλίο εργασίας είναι αρκετά μεγάλο ώστε ένα πλήρες αντίγραφο στη μνήμη να είναι εντελώς λάθος σχήμα, ο streaming direct reader για υπερμεγέθη υπολογιστικά φύλλα είναι το καλύτερο σημείο εισόδου
Γιατί ένα κρυπτογραφημένο XLSX μοιάζει με αρχείο XLS;
Γιατί είναι, σε επίπεδο δοχείου — και αυτό είναι το πρακτικό όφελος του να κατέχεις αυτό το επίπεδο. Άνοιξε ένα κρυπτογραφημένο .xlsx σε hex editor και τα πρώτα οκτώ bytes είναι D0 CF 11 E0 A1 B1 1A E1, byte προς byte πανομοιότυπα με ένα .xls εποχής 1997, γιατί η κρυπτογράφηση [MS-OFFCRYPTO] δεν κρυπτογραφεί το πακέτο ZIP επί τόπου: τυλίγει ολόκληρο το πακέτο μέσα σε ένα δοχείο CFB ως ένα stream ονόματι EncryptedPackage, δίπλα σε ένα stream EncryptionInfo που περιγράφει τον αλγόριθμο. Η υπογραφή επομένως ταυτοποιεί το δοχείο και δεν λέει τίποτα για το payload. Το να ξεχωρίσεις ένα βιβλίο εργασίας BIFF από ένα κρυπτογραφημένο πακέτο OOXML σημαίνει να διαβάσεις τον κατάλογο, που μετά το LoadFromStream είναι μια σάρωση πάνω στα EntryCount και Entries, ή ένα ζευγάρι δοκιμών HasStream
type
TCfbPayload = (cpUnknown, cpBiffWorkbook, cpEncryptedOoxml);
function ClassifyContainer(AStream: TStream): TCfbPayload;
var
Cfb: TlxCompoundFile;
E: TlxCfbEntry;
I: Integer;
begin
Result:= cpUnknown;
Cfb:= TlxCompoundFile.Create;
try
Cfb.LoadFromStream(AStream);
for I:= 0 to Cfb.EntryCount - 1 do
begin
E:= Cfb.Entries(I);
if E.EntryType <> cfbStream then
Continue;
if E.Name = 'EncryptedPackage' then
Result:= cpEncryptedOoxml
else if (E.Name = 'Workbook') or (E.Name = 'Book') then
Result:= cpBiffWorkbook;
end;
finally
Cfb.Free;
end;
end;
Τα ονόματα καταλόγου αξίζουν τη δική τους προειδοποίηση: τα streams πληροφοριών σύνοψης φέρουν έναν χαρακτήρα ελέγχου 0x05 στην αρχή των ονομάτων τους, οπότε μια σύγκριση γραμμένη έναντι ενός απλού string εμφάνισης δεν θα τα ταιριάξει ποτέ και μια αφελής γραμμή log τα αποδίδει ως σκουπίδια. Οτιδήποτε βρίσκεται μετά από αυτή την ταξινόμηση — παραγωγή του κλειδιού, έλεγχος του password verifier — είναι ξεχωριστό πρόβλημα, που καλύπτεται στις σημειώσεις για γιατί το Excel απορρίπτει ένα βιβλίο εργασίας κρυπτογραφημένο με λάθος λειτουργία κρυπτογράφησης. Το επίπεδο δοχείου απλώς σου λέει μπροστά σε ποια πόρτα στέκεσαι
Γράφοντας ένα δοχείο που το Excel θα ανοίξει πράγματι
Η πλευρά εγγραφής του TlxCompoundFile είναι σκόπιμα στενότερη από την πλευρά ανάγνωσης, και το να καταλάβεις γιατί γλιτώνει έναν καβγά με το spec. Το [MS-CFB] επιτρέπει έναν τεράστιο χώρο έγκυρων δοχείων: storages πολλαπλών επιπέδων, σωστά ισορροπημένα δέντρα καταλόγου red-black, mini streams, αλυσίδες DIFAT. Το Excel εκπέμπει μια μικρή γωνιά αυτού του χώρου και διαβάζει μια κάπως μεγαλύτερη. Το HotXLS γράφει μια ακόμα μικρότερη γωνιά — το ελάχιστο που το Excel αποδεδειγμένα φορτώνει. Κάθε stream πηγαίνει στο κανονικό FAT χωρίς μονοπάτι mini-stream, κάτι που κοστίζει χώρο δίσκου και αγοράζει ορθότητα: ένα stream σύνοψης 300 bytes που το Excel θα είχε πακετάρει σε πέντε mini τομείς των 64 bytes καταλαμβάνει αντ' αυτού έναν πλήρη τομέα 512 bytes, και για ένα βιβλίο εργασίας αυτό είναι θόρυβος δίπλα στη συντήρηση ενός δεύτερου πίνακα εκχώρησης, μιας δεύτερης διάτρεξης αλυσίδας και του stream ριζικής καταχώρησης που το υποστηρίζει στο μονοπάτι εγγραφής. Οι καταχωρήσεις καταλόγου σχηματίζουν μια επίπεδη αλυσίδα αδελφών κάτω από τη ρίζα με κάθε κόμβο χρωματισμένο μαύρο, και η σειρά εκπομπής είναι σταθερή: δεσμευτής θέσης κεφαλίδας, τομείς δεδομένων stream, τομείς καταλόγου, τομείς FAT, και μετά μια επαναφορά για να ξαναγραφτεί η κεφαλίδα με τα IDs τομέων που γίνονται γνωστά μόνο στο τέλος. Το FAT προσαρμόζει το μέγεθός του μέσω ενός σύντομου βρόχου σταθερού σημείου, γιατί η προσθήκη τομέων FAT μπορεί να ωθήσει τον αριθμό τομέων αρκετά ψηλά ώστε να απαιτηθεί ακόμα ένας τομέας FAT
procedure SaveAsCompoundFile(const Dest: string; const BiffBytes: TBytes);
var
FS: TFileStream;
Cfb: TlxCompoundFile;
begin
FS:= TFileStream.Create(Dest, fmCreate);
try
Cfb:= TlxCompoundFile.Create;
try
Cfb.CreateNew(FS); // v3 header, 512-byte sectors
Cfb.AddStream('Workbook', BiffBytes);
Cfb.Save; // data -> dir -> FAT -> header
finally
Cfb.Free;
end;
finally
FS.Free;
end;
end;
Πού σταματά η υλοποίηση
Αξίζει να δηλωθούν καθαρά τρία όρια, γιατί ένας αναγνώστης δοχείου που χειρίζεται σιωπηρά και λάθος μια οριακή περίπτωση είναι χειρότερος από έναν που σηκώνει εξαίρεση. Το TlxCompoundFile διαβάζει τις 109 καταχωρήσεις DIFAT που κατοικούν στην κεφαλίδα και δεν ακολουθεί την αλυσίδα DIFAT στο 0x44 πέρα από αυτές, θέτοντας ανώτατο όριο σε ένα αναγνώσιμο δοχείο περίπου 6,8 MiB σε τομείς 512 bytes — άνετα πάνω από τα πραγματικά αρχεία .xls που συναντά το HotXLS στο πεδίο, αλλά σκληρή οροφή παρ' όλα αυτά, και ο εγγραφέας επιβάλλει το ίδιο όριο ρητά αντί να εκπέμπει ένα δοχείο που δεν μπορεί να περιγράψει. Δεύτερον, δοχεία έκδοσης 4 με τομείς 4096 bytes εξυπηρετούνται από την αριθμητική μεγέθους τομέα αλλά δεν είναι αυτό για το οποίο είναι συντονισμένος ο κώδικας, και το μέγεθος stream 64-bit δεν συμβουλεύεται: το HotXLS διαβάζει τα χαμηλά 32 bits στη θέση 0x78 και αφήνει το ψηλό μισό ανέγγιχτο, κάτι που είναι σωστό για την έκδοση 3 και μόνο για την έκδοση 3. Τρίτον, η αναζήτηση καταχώρησης είναι μια επίπεδη σάρωση κατ' όνομα σε όλη τη λίστα καταλόγου αντί για μια διάτρεξη στο δέντρο red-black από ένα γονικό storage, οπότε τα εμφωλευμένα storages επιλύονται με σύγκρουση ονόματος αντί για διαδρομή — κάθε stream που χρειάζεται ένα αρχείο .xls κάθεται στο ανώτατο επίπεδο, που είναι αυτό που κάνει τον απλούστερο σχεδιασμό υπερασπίσιμο, αλλά κώδικας που περιμένει να απευθυνθεί σε SomeStorage/SomeStream δεν θα το βρει
Τίποτα από αυτά δεν αλλάζει σε τι χρησιμεύει το unit. Το να κατέχεις το επίπεδο δοχείου μετατρέπει τον χειρισμό .xls σε συνηθισμένη Object Pascal: αναλύσιμο από έναν πίνακα bytes, δοκιμάσιμο χωρίς σύστημα αρχείων, μεταφέρσιμο σε όποια πλατφόρμα στοχεύει ο compiler, και απαλλαγμένο από COM apartment. Επίσης συνταξιοδοτεί τις συντομεύσεις μυρίσματος, γιατί η ταυτοποίηση ενός βιβλίου εργασίας τώρα σημαίνει να διαβάσεις τον κατάλογό του αντί για τα πρώτα οκτώ bytes του — η ίδια πειθαρχία πίσω από την παράθεση ονομάτων φύλλων χωρίς να ανοίξεις ολόκληρο το βιβλίο εργασίας
Το TlxCompoundFile διατίθεται ως μέρος του HotXLS Excel Component για Delphi και C++Builder, μαζί με τα επίπεδα BIFF και OOXML που κάθονται από πάνω του· η σελίδα του προϊόντος φέρει την πλήρη αναφορά unit και τον υποστηριζόμενο πίνακα compiler