PDFlibPas، کتابخانهٔ PDF losLab برای Delphi، از v3.539.45 درختهای name و number در PDF را با یک پشتهٔ صریح و یک مجموعهٔ ملاقاتشده میپیماید، پس /Kids حلقوی و فرزندان مشترک و درختهایی که هزاران سطح عمق دارند دیگر پشتهٔ فراخوانی را نمیکشند و مدخل تکراری تولید نمیکنند. از v3.539.51 جفت /Limits گمشده یا بدشکل یا معکوس هرگز شاخهای که کلید را دارد پنهان نمیکند. مقصدهای نامدار و برچسبهای صفحه و پیوستها و JavaScript سطح سند همگی از این دو مسیر کد میخوانند، و همین آنها را بخشی از attack surface هر PDF ای میکند که خودت تولیدش نکردهای
محرک بهندرت عجیب است. یک fuzzer یا یک آپلود خصمانه یا یک ذخیرهٔ افزایشی باگدار یک درایهٔ /Kids مینویسد که به جدش اشاره میکند، و یک پیمایگر بازگشتی روی یک فایل دو کیلوبایتی با stack overflow میمیرد. خرابی آرامتر یک lookup است که به یک آرایهٔ /Limits شکسته اعتماد میکند و برای مقصدی که واضحاً آنجاست «یافت نشد» گزارش میدهد
درختهای name و number کجای PDF پیدایشان میشود؟
درختهای name و number هر جا که PDF مجموعه بزرگی از کلیدها را به اشیاء نگاشت میکند پیدایشان میشود، و PDFlibPas دستکم چهار تایشان را از طریق APIهای عمومی میخواند. ISO 32000-1 §7.9.6 درخت name را تعریف میکند (کلیدهای رشتهای، جدول 36) و §7.9.7 درخت number را (کلیدهای صحیح، جدول 37). هر دو درختهایی تقریباً متوازناند که ریشه و گرههای میانیشان /Kids دارند، برگهایشان جفتهای مرتب کلید/مقدار را در /Names یا /Nums حمل میکنند، و گرههای غیرریشهشان یک آرایهٔ /Limits دوم عضوی با کوچکترین و بزرگترین کلید زیرشان دارند
| درخت | کجا زندگی میکند | مشخصه | API خواندن PDFlibPas |
|---|---|---|---|
| مقصدهای نامدار | /Dests در dictionary نامها | §12.3.2.3 | GetNamedDestination، بعد GetDestPage / GetDestType |
| برچسبهای صفحه | /PageLabels در catalog (درخت number) | §12.4.2 | GetPageLabel |
| پیوستها | /EmbeddedFiles در dictionary نامها | §7.7.4، §7.11.4 | EmbeddedFileCount، GetEmbeddedFileStrProperty |
| JavaScript سطح سند | /JavaScript در dictionary نامها | §7.7.4 | GlobalJavaScriptCount، GlobalJavaScriptPackageName |
دو جزئیات در آن جدول بهراحتی از چشم میافتند. مقصدهای نامدار یک فرم قدیمیتر PDF 1.1 هم دارند، یک dictionary سادهٔ /Dests در catalog با کلیدهای شیء نام، و GetNamedDestination قبل از فرورفتن به درخت name در PDF 1.2 اول آن dictionary را چک میکند. و GetDocJavaScript اصلاً خوانندهٔ درخت name نیست: اسکریپتهای چسبیده به تریگرهای سند در dictionary /AA کاتالوگ را برمیگرداند (WS، DS، WP، DP، DC)، در حالی که بستههای اسکریپت نامداری که وقتی سند باز میشود اجرا میشوند در درخت name یعنی /JavaScript زندگی میکنند
هر بایت از آن ساختارها از فایل میآید. مشخصه میگوید یک نویسنده چه چیزی باید تولید کند؛ نمیتواند جلوی خوانندهای را بگیرد که چیز دیگری دریافت میکند، که همان درسی است پشت سختسازی یک parser PDF پاسکال در برابر فایلهای مخرب، اینجا اعمالشده به شکل درخت نه اندازهٔ بافرها
چرا یک آرایهٔ /Kids حلقوی یک پیمایگر بازگشتی درخت را crash میکند؟
یک آرایهٔ /Kids حلقوی یک پیمایگر بازگشتی را crash میکند چون هیچچیز در بازگشت متوجه نمیشود قبلاً گرهای را دیده، پس فرزندی که به جد خودش ارجاع میدهد یک فایل متناهی را به یک فرورفتن نامتناهی تبدیل میکند. قبل از v3.539.45، NameTreeLookup و NumTreeLookup و EnumNumTree و TPDFNameTree.ProcessNode داخلی همه بهازای هر فرزند یک بار خودشان را صدا میزدند. یک ارجاع به خود کافی بود که فرایند را تمام کند، و یک درخت مشروع اما خیلی عمیق هم بدون هیچ cycle ای میتوانست همان کار را بکند
یک واریانت ملایمتر بهجای crash نتیجه را خراب میکند. وقتی دو درایهٔ /Kids به یک برگ ارجاع میدهند، یک شمارش سادهلوحانه دو بار به آن سر میزند، و یک شمارندهٔ پیوست یا فهرست بستههای اسکریپت مدخلهایی را گزارش میکند که وجود ندارند
فیکس بازگشت را با یک پشتهٔ صریح آخرین-داخل-اولین-خروج روی heap و یک مجموعهٔ ملاقاتشده با کلید identity dictionary جایگزین میکند. گره وقتی pop میشود علامت میخورد نه وقتی push میشود، پس یک ارجاع حلقوی میتواند مدت کوتاهی روی پشته بنشیند اما لحظهای که برمیگردد بالا دور انداخته میشود. هر گره متمایز فرزندانش را دقیقاً یک بار باز میکند، که کل کار را به تعداد dictionaryهای متمایز بهعلاوهٔ طول کل آرایههای /Kids شان محدود میکند. عمق دیگر مهم نیست: یک زنجیرهٔ 4,096 سطحی فقط 4,096 تکرار حلقه و 4,096 مدخل در یک hash set است
اما ترتیب هنوز مهم است و پشته باید معکوس تغذیه شود تا حفظ شود. فرزندان از آخرین اندیس به اولی push میشوند، پس چپترین فرزند اول pop میشود و برگها به همان ترتیب چپبهراستی بیرون میآیند که تولیدکننده نوشته. GetPageLabel به همین وابسته است: هر بازهٔ شمارششده را میپیماید و آخری را اعمال میکند که اندیس شروعش روی یا زیر صفحه باشد، پس معکوس کردن شمارش بیسروصدا سبک مقدمات جلدی را به صفحهٔ 200 میداد. اسکلت زیر الگو را روی یک نوع گرهٔ انتزاعی نشان میدهد، مستقل از هر مدل شیء PDF
uses
System.Generics.Collections;
type
TTreeNode = class
public
Kids: TArray<TTreeNode>; // روی برگ خالی
Keys: TArray<string>; // کلیدهای برگ، مرتبشده توسط یک تولیدکنندهٔ خوب
Values: TArray<Integer>; // موازی با Keys
HasLimits: Boolean;
LoKey, HiKey: string;
end;
// /Limits یک سرنخ است: فقط جفت درستفرم و مرتب میتواند شاخهای را حذف کند
function LimitsExclude(Node: TTreeNode; const Key: string): Boolean;
begin
Result := Node.HasLimits and (Node.LoKey <= Node.HiKey) and
((Key < Node.LoKey) or (Key > Node.HiKey));
end;
function FindValue(Root: TTreeNode; const Key: string;
out Value: Integer): Boolean;
var
Pending: TList<TTreeNode>;
Visited: TDictionary<TTreeNode, Byte>;
Node: TTreeNode;
I: Integer;
begin
Result := False;
Value := 0;
if Root = nil then
Exit;
Pending := TList<TTreeNode>.Create;
Visited := TDictionary<TTreeNode, Byte>.Create;
try
Pending.Add(Root);
while Pending.Count > 0 do
begin
Node := Pending[Pending.Count - 1];
Pending.Delete(Pending.Count - 1);
if Visited.ContainsKey(Node) then
Continue; // cycle یا فرزند مشترک: دیدهایم
Visited.Add(Node, 0);
if Length(Node.Kids) > 0 then
begin
// راستبهچپ push کن تا چپترین فرزند اول pop شود
for I := High(Node.Kids) downto 0 do
if (Node.Kids[I] <> nil) and not LimitsExclude(Node.Kids[I], Key) then
Pending.Add(Node.Kids[I]);
end
else
for I := 0 to High(Node.Keys) do
if (Node.Keys[I] = Key) and (I <= High(Node.Values)) then
begin
Value := Node.Values[I];
Exit(True);
end;
// یافتن نشدن در این برگ حکم نیست: به pop کردن خواهرها ادامه بده
end;
finally
Visited.Free;
Pending.Free;
end;
end;
چرا lookup نمیتواند روی اولین شاخهٔ منطبق توقف کند؟
یک lookup نمیتواند روی اولین شاخهای که بازهاش منطبق است توقف کند، چون بازههای /Limits در یک فایل واقعی میتوانند همپوشانی داشته باشند یا دروغ بگویند، و شاخهای که مدعی کلید است لزوماً شاخهای نیست که آن را دارد. lookupهای قبل از v3.539.45 روی اولین فرزندی که /Limits اش کلید را میپوشاند یک فلگ Found میگذاشتند، داخلش فرورفته و دیگر به خواهر دیگری نگاه نمیکردند. اگر آن فرزند خالی درمیآمد یا کهنه بود یا حلقهای به ریشه، جواب nil بود، حتی وقتی خواهر بعدی همان کلید را داشت
FindTreeValue بازنویسیشده که حالا پشت هر دو NameTreeLookup و NumTreeLookup است، هر فرزندی که بازهاش کلید را حذف نکند push میکند و تا پیدا کردن یک تطبیق یا خالی شدن پشته به pop کردن ادامه میدهد. یافتن نشدن داخل یک برگ فقط یافتن نشدن داخل یک برگ است. در درختی درستفرم این هیچ هزینهٔ اضافهای ندارد؛ در درختی خراب چند ملاقات گره بیشتر هزینه دارد و جواب درست را برمیگرداند
جستوجوی برگ هم همین فلسفه را دنبال میکند. ISO 32000-1 میخواهد کلیدهای یک آرایهٔ /Names بهترتیب مقدار بایت مرتب باشند، پس برگ اول با جستوجوی دودویی جستوجو میشود. اگر آن شکست بخورد، PDFlibPas به اسکن خطی جفتها برمیگردد، چون برگ خارجازترتیب وگرنه یک کلید حاضر را نامرئی میکرد. مرتبسازی مسیر سریع است، نه فیلتر
lookup همچنین از حدس زدن روی یک تناقض ساختاری خودداری میکند. جدول 36 اجازه میدهد گره یا /Kids داشته باشد یا /Names، هرگز هر دو را، و مسیر lookup گرهای که هر دو را حمل میکند بدشکل تلقی و آن را skip میکند بهجای اینکه یک تفسیر را انتخاب کند. مسیرهای شمارش مثل EnumNumTree بخشندهترند و وقتی هر دو حاضرند /Kids را دنبال میکنند
یک خواننده اجازه دارد برای چه به /Limits اعتماد کند؟
یک خواننده فقط برای رد کردن کار اجازه دارد به /Limits اعتماد کند، هرگز برای تصمیم گرفتن اینکه کلیدی غایب است، و فقط وقتی جفت درستفرم باشد. جدول 36 میگوید گرههای میانی و برگ باید /Limits را بهشکل آرایهای دوم عضوی از کمترین و بزرگترین کلیدها حمل کنند، اما در عمل آن درایه بعد از ویرایشهای دستی گم میشود، یا اعداد را در یک درخت name نگه میدارد، یا با مرزهای عوضشده میرسد. PDFlibPas v3.539.45 و v3.539.51 هر حالت را به یک شکل حل میکنند: اگر بازه نتوان بهشکل جفت مرتبی از نوع درست خوانده شود، فرزند قابلجستوجو میماند
/Limitsگمشده: چک بازهٔ قدیمی False برمیگرداند و فرزند کلاً skip میشد، پس تولیدکنندهای که درایه را فراموش میکرد کل زیردرختش را غیرقابلدسترس میکرد. از v3.539.45 فرزند جستوجو میشود- نوع اشتباه یا طول اشتباه، مثل اعداد در یک درخت name یا آرایهای تکعضوی: از v3.539.45 دقیقاً مثل یک درایهٔ گمشده رفتار میشود
- مرزهای معکوس مثل
[(Z) (A)]یا[9 0]: v3.539.45 هنوز از آنها استفاده میکرد و هیچ کلیدی نمیتوانستLo <= Key <= Hiرا وقتیLo > Hiبرآورده کند، پس شاخه برای هر lookup حذف میشد. از v3.539.51 یک بازه فقط وقتی برای حذف استفاده میشود که مرز پایینش از مرز بالایش تجاوز نکند - درستفرم، مرتب و درست: برای حذف شاخه استفاده میشود، که تمام هدف درایه همین است
کلیدهای واقعی در هر حالتی نتیجه را تعیین میکنند. یک /Limits خصمانه میتواند PDFlibPas را وادار کند گرههای بیشتری از لازم ببیند، اما یک /Limits بدشکل دیگر نمیتواند مقصدی موجود را ناپدید کند. از سمت فراخواننده هیچ چیزی عوض نمیشود: GetNamedDestination وقتی نام واقعاً غایب است 0 برمیگرداند و در غیر این صورت یک ID مقصد، و توابع مقصد از آنجا ادامه میدهند
uses
PDFlibrary;
procedure LookUpDestination(const FileName, DestName: string);
var
Lib: TPDFlib;
DestID: Integer;
begin
Lib := TPDFlib.Create;
try
if Lib.LoadFromFile(FileName, '') <> 1 then
begin
WriteLn('Load failed, error ', Lib.LastErrorCode);
Exit;
end;
// اول /Dests در کاتالوگ (PDF 1.1)، بعد درخت name یعنی /Dests
DestID := Lib.GetNamedDestination(DestName);
if DestID = 0 then
WriteLn('No destination named ', DestName)
else if Lib.GetDestPage(DestID) = 0 then
WriteLn(DestName, ' exists but does not resolve to a page')
else
WriteLn(DestName, ' -> page ', Lib.GetDestPage(DestID),
', view type ', Lib.GetDestType(DestID)); // 1 = XYZ، 2 = Fit و ...
finally
Lib.Free;
end;
end;
روی یک فایل دستساز که ریشهٔ /Dests اش یک فرزند دارد که زیر بازهٔ [(a) (z)] به ریشه برمیگردد و یک فرزند دوم که مدخل واقعی را زیر limits معکوس یعنی [(z) (a)] نگه میدارد، این رویه مقصد را به صفحهٔ 2 با نوع دید 2 یعنی Fit حل میکند. قبل از v3.539.45 همان lookup مقدار 0 برمیگرداند، چون فرزند حلقهای اول مدعی کلید میشد و جستوجو هرگز به خواهرش نمیرسید؛ v3.539.45 تنها هم همچنان 0 برمیگرداند، چون بازهٔ معکوس برگ واقعی را حذف میکرد. اگر بعدش outline ای را بخوانی که به این مقصدها اشاره میکند، مقالهٔ همراه دربارهٔ خواندن اکشنهای بوکمارک و حاشیهنویسی PDF در Delphi سمت action را پوشش میدهد
برگی با 32,769 نام چطور TPDFNameTree را شکست؟
برگی با 32,769 جفت نام/مقدار TPDFNameTree را میشکست چون FindIndex داخلیاش دو عدد را در یک Integer 32 بیتی بستهبندی میکرد: موقعیت برگ در فهرست آرایهٔ داخلی در 16 بیت بالا و آفست مدخل داخل آرایهٔ /Names آن برگ در 16 بیت پایین. هر جفت دو خانهٔ آرایه اشغال میکند، پس جفت 32,769 یعنی جفت با اندیس 32,768 از آفست 65,536 شروع میشود که همان $10000 است. آن مقدار به نیمهٔ بالا انتقال میبرد و decoder آن را بهعنوان آفست 0 در برگ بعدی میخواند
TPDFNameTree کلاسی است که پشت پیوستها و بستههای JavaScript سراسری و نوشتن مقصدهای نامدار است، که پیامدهایش را ملموس میکند. در درخت تکبرگی برگ بعدی وجود ندارد، پس FindKey و DeleteKey از انتهای فهرست برگ بیرون اندیس میزدند؛ در درخت چندبرگی بهجای جفت درخواستی اولین جفت برگ بعدی را برمیگرداندند یا حذف میکردند. در همین حین HasKey اسکن خودش را اجرا میکرد و کلید را حاضر گزارش میکرد، پس کلاس با خودش تناقض داشت. یک مرجع دستی تولیدشده با یک مقصد نامدار برای هر نماد API بدون زور از 32,768 مدخل میگذرد، و بعضی تولیدکنندهها همه را در یک برگ مسطح تکی مینویسند
از v3.539.45 FindIndex اندیس آرایه را از طریق یک پارامتر out جدا برمیگرداند و آفست کامل مدخل را بهعنوان نتیجه، پس هیچکدام بریده نمیشوند. همان release دو همسایه را هم سختگیرانهتر کرد. KeyName حالا فقط کلیدهای رشتهای واقعی را میشمارد و برمیگرداند و برای اندیس 0 یا کمتر رشتهٔ خالی برمیگرداند، جایی که قبلاً هر شیئی را که بعد از یک کلید نامعتبر میآمد cast میکرد. HasKey دیگر یک کلید عددی یا نامعتبر را نام خالی حساب نمیکند. برای برگای مثل [(Valid) 42 123 456]، HasKey('') حالا False است و KeyName(2) رشتهٔ خالی برمیگرداند
procedure AuditTrees(const FileName: string);
var
Lib: TPDFlib;
I: Integer;
begin
Lib := TPDFlib.Create;
try
if Lib.LoadFromFile(FileName, '') <> 1 then
Exit;
// درخت number یعنی /PageLabels؛ فایلهای بدون آن شمارهٔ صفحهٔ ساده برمیگردانند
for I := 1 to Lib.PageCount do
WriteLn('Page ', I, ' label: ', Lib.GetPageLabel(I));
// درخت name یعنی /EmbeddedFiles؛ اندیسها 1-مبنا هستند، کلیدهای غیر رشتهای skip میشوند
for I := 1 to Lib.EmbeddedFileCount do
WriteLn('Attachment ', I, ': ', Lib.GetEmbeddedFileStrProperty(I, 1),
' (', Lib.GetEmbeddedFileStrProperty(I, 2), ')'); // نام، نوع MIME
// درخت name یعنی /JavaScript: نام بستهها را فهرست کن، هیچچیز را اجرا نکن
for I := 1 to Lib.GlobalJavaScriptCount do
WriteLn('Script package: ', Lib.GlobalJavaScriptPackageName(I));
finally
Lib.Free;
end;
end;
روی همان فایل دستساز، که ریشهٔ /PageLabels اش یک برگ را دو بار فهرست میکند و به خودش ارجاع میدهد، این ممیزی برای دو صفحه i و A-1 چاپ میکند، هر بازه یک بار، و بستهٔ اسکریپت تکی از یک درخت /JavaScript که آن هم به ریشهٔ خودش اشاره میکند. سمت نوشتن برچسبهای صفحه تاریخچهٔ خودش را با ریشههای /Kids دارد، پوشش دادهشده در فیکس کردن برچسبهای صفحهٔ PDF ذخیرهشده در درختهای number با /Kids؛ AddPageLabels چنین ریشهای را قبل از درج مسطح میکند و به همان شمارش EnumNumTree که اینجا توضیح داده شد تکیه دارد
این سختسازی هنوز چه چیزی را تضمین نمیکند؟
سختسازی پایانپذیری و ترتیب پایدار و نتایج درست برای درختهایی که کلیدهای واقعیشان سالماند را تضمین میکند؛ درخت خراب را به معنای قصد نویسندهاش درنمیآورد. چند محدودیت ارزش دانستن دارند قبل از اینکه روی آن بسازی
- مجموعهٔ ملاقاتشده با identity شیء کار میکند. دو dictionary متمایز با محتوای یکسان دو گرهاند، پس تولیدکنندهای که بهجای ارجاع دادن یک برگ را کپی میکند همچنان مدخل تکراری تولید میکند
- یک
/Limitsدرستفرم و مرتب اما غلط هنوز حذف میکند. خوانندهای که از بازهها بهعنوان بهینهسازی استفاده میکند نمیتواند همزمان نسبت به بازهای که باورپذیر دروغ میگوید مصون باشد؛ تنها جایگزین، نادیده گرفتن کامل/Limitsو اسکن کردن هر برگ است - شمارش ترتیب فایل را حفظ میکند اما مرتب نمیکند.
GetPageLabelآخرین بازهٔ شمارششدهٔ روی یا زیر صفحه را اعمال میکند، پس تولیدکنندهای که بازهها را خارج از ترتیب مینویسد معناشناسی ترتیبفایل میگیرد - حافظه با تعداد گرهها و مدخلهای متمایز رشد میکند. پیمایش یک فهرست و یک hash set اضافه میکند، هیچ چیز بیشتر، اما یک درخت name صد مگابایتی بعد از parse هم همچنان درخت name صد مگابایتی است
- کلیدهای تکراری داخل یک برگ گزارش نمیشوند. جستوجوی دودویی هر جفت منطبقی که اول به آن بخورد را برمیگرداند؛ fallback خطی آخرین منطبقی که اسکن میکند را نگه میدارد
مرجع سریع: خواندن درختهای PDF از فایلهای غیرقابلاعتماد
- برای پیمایش مقاوم به cycle و مقاوم به پشتهٔ درختهای name و number به v3.539.45 یا بعدتر ارتقا بده، و به v3.539.51 یا بعدتر تا
/Limitsمعکوس دیگر کلیدها را پنهان نکنند - 0 برگرداندن
GetNamedDestinationرا «غایب» بگیر و 0 برگرداندنGetDestPageرا «حاضر اما غیرقابلاستفاده» - برای درخت name یعنی
/JavaScriptازGlobalJavaScriptCountوGlobalJavaScriptPackageNameاستفاده کن؛ GetDocJavaScriptبهجایش تریگرهای/AAکاتالوگ را میخواند - پیوستها و بستههای اسکریپت را از 1 تا شمارندهای که کتابخانه گزارش میکند اندیس بزن؛ کلیدهای نامعتبر شمرده نمیشوند
- در کد درخت خودت، گرهها را روی pop ملاقاتشده علامت بزن، فرزندان را معکوس push کن، و بگذار
/Limitsفقط وقتی یک جفت درستنوع و مرتب است حذف کند
ابزارهای پیشپرواز و آرشیوگرها و viewerها این درختها را قبل از رندر شدن هر صفحه میخوانند، پس باید از هر چیزی که در صف آپلود میرسد جان سالم به در ببرند. خوانندههای درخت توصیفشده در بالا با PDFlibPas، کتابخانهٔ PDF برای Delphi عرضه میشوند که با هر دو Delphi و Free Pascal بیلد میشود