บทความเทคนิค

ข้อผิดพลาดการตรวจสอบช่วงในไลบรารี PDF ของ Delphi: สาเหตุที่แท้จริง

ข้อผิดพลาด Range check ในไลบรารี PDF ของ Delphi ขึ้นชื่อว่าตรวจพบได้ยาก เพราะไม่ได้เกิดขึ้นในรูปแบบที่ตายตัว เอกสารเดียวกันอาจทำให้เกิดข้อผิดพลาดในเครื่องหนึ่งแต่อีกเครื่องไม่เป็นไร หรือโค้ดส่วนเดียวกันอาจเกิด Exception กับไฟล์ที่มี 3 หน้า แต่ทำงานได้ปกติกับไฟล์ที่มี 12 หน้า ความไม่สม่ำเสมอนี้มักเกิดจากสาเหตุหลักเพียงสาเหตุเดียว นั่นคือ วัตถุหน้า (page objects) ในไฟล์ PDF ไม่ได้ถูกจัดเก็บตามลำดับเสมอไป หากไลบรารีสร้างอาร์เรย์ของหน้าภายในโดยการสแกนวัตถุตามลำดับ แทนที่จะท่องไปตาม tree ของหน้าซึ่งกำหนดไว้ใน catalog ก็จะทำให้ดัชนี (index) ที่สร้างขึ้นมีช่วงขอบเขตที่ไม่ตรงกับที่ผู้เรียกคาดหวัง และการตรวจสอบช่วง (range checking) ก็จะดักจับความไม่สอดคล้องกันนี้ได้ในจังหวะที่แย่ที่สุด

การทำงานของการตรวจสอบช่วง (range checking) ใน Delphi

เมื่อเปิดใช้งาน compiler directive {$R+} (ซึ่งเป็นค่าเริ่มต้นในโหมด Debug) Delphi RTL จะตรวจสอบทุกดัชนีของอาร์เรย์ (array index), ตัวห้อยสตริง (string subscript), และการกำหนดค่า enumerated ในช่วงรันไทม์ (runtime) การเข้าถึงที่เกินขอบเขตจะทำให้เกิด ERangeError แทนที่จะอ่านหน่วยความจำที่อยู่ติดกันแบบเงียบๆ พฤติกรรมนี้มีประโยชน์มาก: มันช่วยเปิดเผยบั๊กแฝงตั้งแต่เนิ่นๆ แทนที่จะปล่อยให้มันทำให้โครงสร้างข้อมูลเสียหายแล้วไปทำงานล้มเหลวในอีกหลายร้อยบรรทัดถัดไป สิ่งที่น่าหงุดหงิดคือ exception จะเกิดขึ้นที่ จุดที่มีการเข้าถึง (access site) ไม่ใช่จุดที่คำนวณดัชนีผิดพลาด เมื่อ call stack แสดงเมธอดที่ซ้อนกันลึกๆ ในยูนิตของ PDF ความผิดพลาดจริงๆ มักจะเกิดขึ้นก่อนหน้านั้นหลายเฟรม

เงื่อนไขบูลีนแบบผสม (Compound boolean conditions) ทำให้เรื่องนี้แย่ลง Delphi ประเมินค่านิพจน์ and จากซ้ายไปขวาด้วย semantics แบบ short-circuit แต่ short-circuiting จะข้ามการประเมินค่าก็ต่อเมื่อฝั่งซ้ายเป็น False เท่านั้น นิพจน์เช่น:

if FDocStarted and (DestIndex < Length(PageArr)) and
   (PageArr[DestIndex].PageObj <> nil) then

ดูเหมือนปลอดภัย แต่มันจะป้องกันดัชนีที่อยู่นอกช่วงได้ก็ต่อเมื่อ FDocStarted เป็น True และ DestIndex ไม่ติดลบ การตรวจสอบ DestIndex < Length(PageArr) จะไม่มีผลเมื่อ DestIndex เป็นค่าติดลบ เพราะการเปรียบเทียบจำนวนเต็มลบกับความยาวที่ไม่ติดลบจะให้ผลเป็น True ในคณิตศาสตร์แบบมีเครื่องหมาย (signed arithmetic) และการเข้าถึงอาร์เรย์ในลำดับถัดไปก็จะทำให้เกิด range error อยู่ดี การย้ายการตรวจสอบขอบเขต (bounds check) ไปไว้ชั้นนอกสุดคือการแก้ไขที่ถูกต้อง:

if (DestIndex >= 0) and (DestIndex < Length(PageArr)) then
begin
  if FDocStarted and (PageArr[DestIndex].PageObj <> nil) then
    Result := PageArr[DestIndex].PageObj
  else
    Result := nil;
end
else
  raise ERangeError.CreateFmt(
    'Page index %d is out of range (0..%d)',
    [DestIndex, Length(PageArr) - 1]);

นี่คือการแก้ไขเชิงกลไก มันหยุดการแครช (crash) ได้ แต่มันไม่ได้อธิบายว่าทำไม DestIndex ถึงได้รับค่านอกช่วงที่ถูกต้องตั้งแต่แรก

สาเหตุที่แท้จริง: ลำดับของวัตถุกับลำดับหน้า

มาตรฐาน ISO 32000-1 หมวด §7.7.3 นิยาม page tree ว่าเป็น tree ของโหนด Pages ซึ่งมีอาร์เรย์ Kids แสดงรายการ page objects ตามลำดับการแสดงผล ไฟล์จะจัดเก็บวัตถุเหล่านั้นไว้ที่ออฟเซ็ตใดๆ ก็ตามที่ผู้สร้างเลือก วัตถุหมายเลข 20 อาจอยู่ก่อนหน้าวัตถุหมายเลข 3 ทางกายภาพในสตรีมของไบต์ (byte stream) ไลบรารีที่สร้างรายการหน้าโดยการวนซ้ำในตารางอ้างอิงไขว้ (cross-reference table) ตามลำดับหมายเลขวัตถุ แทนที่จะตามลำดับ Kids จะสร้างลำดับที่แตกต่างไปจากที่ผู้ใช้คาดหวัง ในเอกสารที่ผู้สร้างบังเอิญเขียนหน้าตามลำดับ ทุกอย่างก็จะทำงานได้ปกติ แต่ในเอกสารที่ไม่ได้เป็นเช่นนั้น ความคลาดเคลื่อนระหว่างการเรียงหมายเลขหน้าของไลบรารีกับการเรียงหมายเลขหน้าของผู้เรียกจะทำให้เกิดดัชนีที่ตกอยู่นอก PageArr

วิธีที่ถูกต้องคือเริ่มจาก catalog เพื่อแก้ไขค่า indirect reference /Pages และท่องอาร์เรย์ Kids แบบเรียกซ้ำ (recursive) สำหรับเอกสารแบบแบนที่ไม่มีโหนด Pages คั่นกลาง การท่องไปใน tree จะตรงไปตรงมา:

procedure BuildPageIndexFromTree(
  const KidsArray: THPDFArray;
  var PageArr: TPageObjArray);
var
  i, Idx: Integer;
  Child: THPDFObject;
  ChildType: string;
begin
  for i := 0 to KidsArray.Count - 1 do
  begin
    Child := KidsArray.GetIndirectObject(i);
    if Child = nil then
      Continue;
    ChildType := Child.GetNameValue('/Type');
    if ChildType = 'Page' then
    begin
      Idx := Length(PageArr);
      SetLength(PageArr, Idx + 1);
      PageArr[Idx].PageObj := Child;
    end
    else if ChildType = 'Pages' then
    begin
      // intermediate node: recurse into its Kids
      BuildPageIndexFromTree(Child.GetArray('/Kids'), PageArr);
    end;
  end;
end;

หลังจากทำงานส่วนนี้ PageArr[0] จะเป็นหน้าแรกที่โปรแกรมดู (viewer) จะแสดงผล ไม่ว่าวัตถุนั้นจะอยู่ตรงไหนใน byte stream ดัชนีที่ส่งผ่านมาจากผู้เรียกซึ่งตั้งสมมติฐานถึงลำดับการแสดงผล ตอนนี้จะจับคู่ได้อย่างถูกต้อง และ range errors ก็จะหยุดลง

การแพตช์แบบฮาร์ดโค้ด (Hard-coded workarounds) ทำให้ปัญหาซับซ้อนขึ้น

ในฐานโค้ดที่ไม่เคยระบุสาเหตุที่แท้จริงได้ เรามักจะพบการแก้ปัญหาเฉพาะหน้า (heuristic patches) เช่น สลับหน้าแรกกับหน้าสุดท้ายถ้ายอดรวมหน้าเท่ากับ 3, หมุนรอบดัชนี (rotate index) สำหรับเอกสารจากโปรแกรมสร้างไฟล์เฉพาะเจาะจง, หรือเพิ่มค่าออฟเซ็ตเมื่อหมายเลขวัตถุแรกเกินเกณฑ์ที่ตั้งไว้ แพตช์แต่ละอย่างเหล่านั้นพอดีกับกลุ่มไฟล์ทดสอบที่มีอยู่ในมือขณะที่เขียนมันขึ้นมา แต่เมื่อเพิ่มไฟล์ PDF จากแหล่งอื่นเข้ามา แพตช์ตัวใดตัวหนึ่งอาจทำงานผิดจังหวะ ทำให้เกิดดัชนีที่ตอนนี้ผิดเป็นสองเท่า: ผิดครั้งแรกเพราะคำนวณมาจากอาร์เรย์ที่ผิดลำดับ และผิดครั้งที่สองเพราะไปประยุกต์ใช้การทำแผนที่ (mapping) ที่ไม่เกี่ยวข้องทับลงไป Range checker จะจับได้ในจุดใดจุดหนึ่งที่ทำงานต่อจากนั้น และ stack trace ก็จะชี้ไปที่ที่ไม่มีประโยชน์เลย

แนวทางที่ได้ผลที่สุดเพียงทางเดียวคือการเอา mapping แบบฮิวริสติก (heuristic mapping) ทั้งหมดออก แล้วแทนที่การสร้างอาร์เรย์ของหน้าด้วยการท่อง tree ที่ถูกต้อง เมื่อดัชนีถูกต้องตั้งแต่กระบวนการสร้าง ก็ไม่จำเป็นต้องมีแพตช์ใดๆ และ range checker ก็จะกลายเป็นสิ่งที่มีประโยชน์แทนที่จะเป็นอุปสรรค

หากคุณกำลังดูแลไลบรารีที่แสดงพฤติกรรมในรูปแบบนี้ ให้เปิดใช้งาน range checking ในโหมด Release เป็นการชั่วคราว แล้วรันกับไฟล์ PDF หลากหลายรูปแบบ: ทั้งเอกสารที่สร้างจาก Word, จาก LaTeX, จากเฟิร์มแวร์ของสแกนเนอร์, หรือจากยูทิลิตี้สำหรับแยกหน้า PDF-to-PDF ไฟล์ที่กระตุ้นให้เกิด exception คือไฟล์ที่มีลำดับของ page object ไม่ตรงกับลำดับการท่อง (traversal order) ที่โค้ดของคุณตั้งสมมติฐานไว้ แต่ละกรณีคือจุดรวบรวมข้อมูลใหม่ ไม่ใช่บั๊กแยกกัน

สำหรับโค้ดใหม่ที่เรียกใช้ไลบรารี PDF ของ Delphi คำแนะนำในทางปฏิบัติคือให้ถือว่าการนับหน้าของไลบรารีนั้นเชื่อถือได้ และอย่าส่งดัชนีที่ได้จากการคำนวณเลขคณิตกับข้อมูลภายนอกเข้าไปโดยไม่ตรวจสอบก่อนว่ามันตกอยู่ในช่วง 0..PageCount - 1 หรือไม่ คอมโพเนนต์ HotPDF จะแสดงจำนวนหน้าผ่าน THotPDF.PageCount หลังจากเรียก BeginDoc หรือหลังจากโหลดเอกสาร; ค่าที่ได้จะสะท้อนการท่อง pages tree เสมอ และปลอดภัยที่จะใช้เป็นขอบเขตบน (upper bound) สำหรับการคำนวณดัชนีใดๆ