บั๊กที่ Rust จับไม่ได้
(corrode.dev)- ความปลอดภัยของหน่วยความจำ ดีขึ้นอย่างมาก แต่แม้ในโค้ด Rust ระดับโปรดักชัน ปัญหาเรื่อง การจัดการขอบเขตระหว่างระบบ ก็ยังคงอยู่และอาจนำไปสู่ช่องโหว่ได้
- โฟลว์ที่ตีความพาธเดิมซ้ำในหลาย syscall, วิธีสร้างแล้วค่อยเปลี่ยนสิทธิ์, และ การเปรียบเทียบพาธด้วยสตริง ล้วนทำให้เกิดปัญหาอย่าง TOCTOU และการเปิดเผยสิทธิ์ได้ง่าย
- บน Unix พาธ ตัวแปรสภาพแวดล้อม และข้อมูลสตรีม ถูกส่งต่อกันในรูป ไบต์ดิบ ดังนั้นการประมวลผลที่ยึด
Stringเป็นหลัก หรือการใช้from_utf8_lossy,unwrap,expectอาจนำไปสู่ข้อมูลเสียหายหรือ DoS ได้ - หากทิ้งข้อผิดพลาดไป ความล้มเหลวอาจดูเหมือนความสำเร็จ และ ความแตกต่างของพฤติกรรม เมื่อเทียบกับ GNU coreutils ก็อาจกลายเป็นปัญหาความปลอดภัยได้ทันทีในเชลล์สคริปต์และเครื่องมือแบบ privileged
- ในการตรวจสอบครั้งนี้ ไม่พบบั๊กตระกูลความปลอดภัยของหน่วยความจำอย่าง buffer overflow, use-after-free, double-free และความเสี่ยงหลักที่ยังเหลืออยู่ก็ไม่ได้อยู่ภายใน Rust เอง แต่ไปกระจุกอยู่ที่ ขอบเขตที่เชื่อมต่อกับโลกภายนอก
ข้อจำกัดของ Rust ที่เปิดเผยจากการตรวจสอบ
- 44 CVE ของ uutils ที่ Canonical เปิดเผย แสดงให้เห็นว่าแม้จะเป็นโค้ด Rust ระดับโปรดักชัน ก็ยังมีช่องโหว่ที่ borrow checker, clippy และ cargo audit จับไม่ได้
- จุดศูนย์กลางของปัญหาอยู่ที่ การจัดการขอบเขตระหว่างระบบ มากกว่าความปลอดภัยของหน่วยความจำ
- มีช่วงเวลาคั่นระหว่างพาธกับ syscall
- ข้อมูลไบต์แบบ Unix กับสตริง UTF-8 ไม่ตรงกัน
- มีพฤติกรรมที่ต่างจากเครื่องมือต้นฉบับ
- มีการละเลยการจัดการข้อผิดพลาดและการจบแบบ
panic!
- รายการ CVE นี้สรุปให้เห็นอย่างกระชับว่า จุดไหนคือ ขอบเขตที่ความปลอดภัยของ Rust สิ้นสุดลง ในโค้ดระบบที่เขียนด้วย Rust
การตีความพาธสองครั้งทำให้เกิด TOCTOU
- ถ้าตรวจสอบพาธเดียวกันใน syscall หนึ่ง แล้วค่อยไปทำงานกับพาธนั้นอีกครั้งใน syscall ถัดไป ก็มีแนวโน้มสูงที่จะนำไปสู่ช่องโหว่แบบ TOCTOU
- ระหว่างสองการเรียก ผู้โจมตีที่มีสิทธิ์เขียนในไดเรกทอรีแม่สามารถเปลี่ยนองค์ประกอบของพาธให้เป็น symbolic link ได้
- ในการเรียกครั้งที่สอง เคอร์เนลจะตีความพาธใหม่ตั้งแต่ต้น ทำให้งานที่มีสิทธิ์พิเศษถูกส่งไปยังเป้าหมายที่ผู้โจมตีเลือก
- API
std::fsของ Rust ใช้การ ตีความใหม่จาก&Pathเป็นค่าเริ่มต้น จึงทำให้พลาดแบบนี้ได้ง่ายfs::metadata,File::create,fs::remove_file,fs::set_permissionsจะตีความพาธใหม่ทุกครั้งที่เรียก- สำหรับเครื่องมือแบบ privileged ที่ต้องป้องกันผู้โจมตีในเครื่อง เส้นทางค่าเริ่มต้นแบบนี้จึงมีความเสี่ยง
- ใน
CVE-2026-35355มีการโจมตีผ่านโฟลว์ที่ลบไฟล์แล้วสร้างไฟล์ใหม่ด้วยพาธเดิม- ใน
src/uu/install/src/install.rsมีfs::remove_file(to)?ต่อด้วยFile::create(to)? - หากระหว่างการลบกับการสร้าง
toถูกเปลี่ยนให้ชี้ไปยัง symbolic link ของเป้าหมายอย่าง/etc/shadowโปรเซสที่มีสิทธิ์สูงก็อาจเขียนทับไฟล์นั้นได้
- ใน
- การแก้ไขเปลี่ยนไปใช้
OpenOptions::create_new(true)เพื่อให้ สร้างได้เฉพาะไฟล์ใหม่เท่านั้น- ตามเอกสาร
create_newไม่ยอมรับทั้งไฟล์เดิมที่มีอยู่แล้วและ dangling symlink ที่ตำแหน่งปลายทาง
- ตามเอกสาร
- หากจำเป็นต้องทำงานกับพาธเดิมสองครั้ง การ ยึดกับ file descriptor จะปลอดภัยกว่า
- นอกเหนือจากกรณีสร้างไฟล์ใหม่ วิธีที่เหมาะสมคือเปิดไดเรกทอรีแม่ครั้งเดียว แล้วทำงานด้วยพาธสัมพัทธ์อ้างอิงจากแฮนเดิลนั้น
- ถ้าต้องทำงานกับพาธเดิมสองครั้ง ให้ถือว่าเป็น TOCTOU ไว้ก่อนจนกว่าจะพิสูจน์ได้ว่าไม่ใช่
อย่าแก้สิทธิ์หลังสร้าง แต่ต้องกำหนดสิทธิ์ตั้งแต่ตอนสร้าง
- โฟลว์ที่สร้างไดเรกทอรีหรือไฟล์ด้วยสิทธิ์เริ่มต้น แล้วค่อย
chmodภายหลัง ก็ทำให้เกิด ช่วงเวลาที่เปิดเผยสิทธิ์ชั่วคราว- หากเขียนแบบ
fs::create_dir(&path)?แล้วตามด้วยfs::set_permissions(&path, Permissions::from_mode(0o700))?ในช่วงนั้นpathจะมีอยู่ด้วยสิทธิ์เริ่มต้น - ผู้ใช้อื่นสามารถ
open()ได้ในช่วงหน้าต่างเวลานั้น และถึงจะchmodภายหลัง file descriptor ที่ได้ไปแล้วก็เรียกคืนไม่ได้
- หากเขียนแบบ
- สิทธิ์ควรถูก กำหนดไปพร้อมกับการสร้าง
- ควรใช้
OpenOptions::mode()และDirBuilderExt::mode()เพื่อให้มันถูกสร้างมาด้วยสิทธิ์ที่ต้องการ - เคอร์เนลจะนำ
umaskมาใช้เพิ่มเติมด้วย ดังนั้นหากผลของมันสำคัญก็ต้องจัดการumaskอย่างชัดเจนเช่นกัน
- ควรใช้
การเปรียบเทียบสตริงของพาธไม่ใช่ความเป็นวัตถุเดียวกันในไฟล์ซิสเต็ม
- การตรวจสอบ
--preserve-rootเวอร์ชันแรกของchmodใช้แค่ การเปรียบเทียบสตริงrecursive && preserve_root && file == Path::new("/")- อินพุตที่จริง ๆ แล้วชี้ไปยังรูท แต่สตริงไม่ใช่
/เช่น/../,/./,/usr/..หรือ symbolic link ที่ชี้ไปยัง/สามารถหลบการตรวจสอบนี้ได้
- การแก้ไขเปลี่ยนไปใช้
fs::canonicalizeเพื่อตีความพาธเป็นพาธสัมบูรณ์จริงก่อนแล้วค่อยเปรียบเทียบ- PR ที่แก้ไข
canonicalizeจะคืนพาธจริงหลังแก้..,., และ symbolic link แล้ว
- ในกรณีของ
--preserve-rootวิธีนี้ใช้ได้เพราะ/ไม่มีไดเรกทอรีแม่ - ถ้าต้องการเปรียบเทียบโดยทั่วไปว่าพาธสองอันอ้างถึงวัตถุเดียวกันในไฟล์ซิสเต็มหรือไม่ ควรเปรียบเทียบ
(dev, inode)แทนสตริง- GNU coreutils ก็ใช้แนวทางนี้เช่นกัน
- ใน
CVE-2026-35363rmปฏิเสธ.และ..แต่ยอมรับ./กับ.///ทำให้สามารถลบไดเรกทอรีปัจจุบันได้- หากจัดการความต่างของรูปแบบอินพุตแค่ในระดับสตริง การตรวจสอบจะถูกเลี่ยงได้ง่าย
ที่ขอบเขตของ Unix ต้องให้ความสำคัญกับไบต์มากกว่าสตริง
Stringและ&strของ Rust เป็น UTF-8 เสมอ แต่พาธ ตัวแปรสภาพแวดล้อม อาร์กิวเมนต์ และข้อมูลสตรีมของ Unix อยู่ในโลกของ ไบต์ดิบ- หากเลือกวิธีผิดตอนข้ามขอบเขตนี้ จะนำไปสู่บั๊กได้สองกลุ่ม
- การแปลงแบบสูญเสียข้อมูล อย่าง
from_utf8_lossyจะเปลี่ยนไบต์ที่ไม่ถูกต้องเป็นU+FFFDและทำให้ข้อมูลเสียหายแบบเงียบ ๆ - การแปลงแบบเข้มงวด อย่าง
unwrapหรือ?อาจปฏิเสธอินพุตหรือทำให้โปรเซสจบการทำงาน
- การแปลงแบบสูญเสียข้อมูล อย่าง
CVE-2026-35346ของcommเป็นกรณีที่เอาต์พุตเสียหายจากการแปลงแบบสูญเสียข้อมูล- ใน
src/uu/comm/src/comm.rsมีการแปลงไบต์อินพุตra,rbเป็นString::from_utf8_lossyแล้วprint! - GNU
commจะส่งผ่านไบต์ตามเดิมแม้เป็นไฟล์ไบนารี แต่ uutils กลับเปลี่ยน UTF-8 ที่ไม่ถูกต้องเป็นU+FFFDทำให้ เอาต์พุตเสียหาย - การแก้ไขคือใช้
BufWriterและwrite_allเพื่อเขียน raw bytes ลงstdoutโดยตรง
- ใน
print!บังคับให้เกิด การไป-กลับผ่าน UTF-8 ผ่านDisplayแต่Write::write_allไม่ทำเช่นนั้น- ในโค้ดระบบบน Unix ต้องเลือกชนิดข้อมูลให้เหมาะกับงาน
- หากอ้อมผ่าน
Stringเพื่อความสะดวกในการฟอร์แมต ความเสียหายของข้อมูล จะซึมเข้ามาได้ง่าย
ทุก panic อาจกลายเป็นการโจมตีแบบปฏิเสธการให้บริการ
- ใน CLI การใช้
unwrap,expect, การเข้าถึงด้วยดัชนีสไลซ์, เลขคณิตที่ไม่ตรวจสอบ,from_utf8อาจกลายเป็น จุด DoS ได้เมื่อผู้โจมตีควบคุมอินพุตได้panic!จะ unwind สแตกและหยุดโปรเซส- หากกำลังทำงานอยู่ใน cron job, CI pipeline หรือ shell script งานทั้งหมดอาจหยุดลง
- ในสภาพแวดล้อมที่รันซ้ำ ๆ มันอาจก่อให้เกิด crash loop จนทำให้ทั้งระบบใช้งานไม่ได้
CVE-2026-35348ของsort --files0-fromหยุดทำงานเมื่อพบ ชื่อไฟล์ที่ไม่ใช่ UTF-8 ในรายการชื่อไฟล์ที่คั่นด้วย NUL- ตัวพาร์เซอร์เรียก
std::str::from_utf8(bytes).expect(...)กับไบต์ของแต่ละชื่อ - GNU
sortจัดการชื่อไฟล์เป็น raw bytes เช่นเดียวกับเคอร์เนล แต่ uutils บังคับ UTF-8 ทำให้ทั้งโปรเซสหยุดทันทีเมื่อเจอพาธแรกที่ไม่ใช่ UTF-8
- ตัวพาร์เซอร์เรียก
- ในโค้ดที่ประมวลผลอินพุตที่เชื่อถือไม่ได้ ควรมอง
unwrap,expect, การ indexing, และascast ว่าเป็น CVE ที่อาจเกิดขึ้นได้- ควรใช้
?,get,checked_*,try_fromและส่งข้อผิดพลาดจริงกลับไปยังผู้เรียก
- ควรใช้
- มีการเสนอเกณฑ์ clippy สำหรับจับเรื่องนี้ใน CI ด้วย
unwrap_usedexpect_usedpanicindexing_slicingarithmetic_side_effects
- ในโค้ดทดสอบ คำเตือนเหล่านี้อาจเข้มเกินไป จึงเหมาะที่จะจำกัดไว้ภายใต้ขอบเขต
cfg(test)
ถ้าทิ้งข้อผิดพลาด ความล้มเหลวอาจดูเหมือนความสำเร็จ
- CVE บางส่วนเกิดจากการเพิกเฉยต่อข้อผิดพลาด หรือจาก โฟลว์ที่ข้อมูลข้อผิดพลาดสูญหายไป
chmod -Rและchown -Rคืนค่าแค่ exit code ของไฟล์สุดท้าย ในการทำงานทั้งหมด- แม้จะล้มเหลวกับไฟล์ก่อนหน้าหลายไฟล์ แต่ถ้าไฟล์สุดท้ายสำเร็จก็อาจจบด้วย
0 - ทำให้สคริปต์เข้าใจผิดว่างานทั้งหมดเสร็จสมบูรณ์แล้ว
- แม้จะล้มเหลวกับไฟล์ก่อนหน้าหลายไฟล์ แต่ถ้าไฟล์สุดท้ายสำเร็จก็อาจจบด้วย
ddเรียกResult::ok()กับผลของset_len()เพื่อเลียนแบบพฤติกรรมของ GNU กับ/dev/null- เจตนาคือทิ้งข้อผิดพลาดเฉพาะในสถานการณ์จำกัด แต่โค้ดเดียวกันกลับถูกใช้กับไฟล์ทั่วไปด้วย
- แม้ดิสก์จะเต็ม ก็อาจเหลือ ไฟล์ปลายทางที่เขียนค้างไว้เพียงครึ่งเดียว แบบเงียบ ๆ
- หากใช้
.ok(),.unwrap_or_default(), หรือlet _ =เพื่อทิ้งResultสาเหตุสำคัญของความล้มเหลวจะหายไป - ถึงจะไม่หยุดทันทีเมื่อเกิดความล้มเหลวครั้งแรก ก็ควรจำ รหัสข้อผิดพลาดที่ร้ายแรงที่สุด ไว้แล้วจบการทำงานด้วยค่านั้น
- หากจำเป็นต้องทิ้ง
Resultจริง ๆ ก็ควร เขียนเหตุผลไว้ในโค้ด ว่าทำไมการเพิกเฉยต่อความล้มเหลวนั้นจึงปลอดภัย
ความเข้ากันได้กับเครื่องมือต้นฉบับอย่างแม่นยำก็เป็นฟีเจอร์ด้านความปลอดภัย
- CVE หลายรายการไม่ได้เกิดเพราะโค้ดทำงานอันตราย แต่เกิดจาก การทำงานต่างจาก GNU
- ในโลกจริง shell script ต่างอาศัยพฤติกรรมของ GNU ต้นฉบับอยู่ และความต่างด้านความหมายเหล่านี้อาจนำไปสู่ปัญหาความปลอดภัยได้
CVE-2026-35369ของkill -1เป็นตัวอย่างชัดเจน- GNU ตีความ
-1เป็น signal 1 และยังต้องการ PID - uutils กลับตีความเป็น การส่งสัญญาณค่าเริ่มต้นไปยัง PID -1
- บน Linux ค่า PID -1 หมายถึงทุกโปรเซสที่มองเห็นได้ ดังนั้นแค่พิมพ์ผิดเล็กน้อยก็อาจกลายเป็นการ kill ทั้งระบบ
- GNU ตีความ
- สำหรับเครื่องมือที่นำมาเขียนใหม่ ความเข้ากันได้แบบ bug-for-bug กลายเป็นมาตรการความปลอดภัยที่ครอบคลุมถึง exit code, ข้อความผิดพลาด, edge case และความหมายของออปชัน
- ทุกจุดที่พฤติกรรมต่างจาก GNU จะเพิ่มโอกาสที่ shell script จะ ตัดสินใจผิดพลาด
- ตอนนี้ uutils รัน upstream GNU coreutils test suite ใน CI ร่วมด้วย
- ดูเป็นขนาดของมาตรการป้องกันที่เหมาะสมสำหรับกันปัญหาประเภทนี้
ต้องตีความให้เสร็จก่อนข้ามขอบเขตความเชื่อถือ
CVE-2026-35368เป็นกรณี local root code execution ของchroot- รูปแบบปัญหาคือเรียก
chroot(new_root)?แล้วค่อยไปตีความชื่อผู้ใช้ภายในรูทใหม่ที่ผู้โจมตีควบคุมได้get_user_by_name(name)?ทำให้มีการอ่าน shared library จากไฟล์ซิสเต็มรูทใหม่เพื่อแปลชื่อผู้ใช้- หากผู้โจมตีวางไฟล์ไว้ภายใน chroot ได้ ก็อาจนำไปสู่ การรันโค้ดด้วย uid 0
- GNU
chrootจะตีความผู้ใช้ ก่อนchroot- การแก้ไขก็เปลี่ยนลำดับให้เป็นแบบเดียวกัน
- เมื่อข้ามขอบเขตความเชื่อถือไปแล้ว การเรียกไลบรารีแต่ละครั้งอาจกลายเป็นการรันโค้ดของผู้โจมตีได้
- แม้แต่การลิงก์แบบสแตติกก็ป้องกันปัญหานี้ไม่ได้
- เพราะ
get_user_by_nameผ่าน NSS และจะdlopenโมดูลlibnss_*ตอนรันไทม์
- เพราะ
บั๊กที่ Rust ป้องกันได้จริง
- มีบั๊กบางประเภทที่ชัดเจนว่า ไม่ถูกพบ ในการตรวจสอบครั้งนี้
- ไม่มี buffer overflow
- ไม่มี use-after-free
- ไม่มี double-free
- ไม่มี data race จากสถานะ mutable ที่ใช้ร่วมกัน
- ไม่มี null-pointer dereference
- ไม่มี uninitialized memory read
- แม้เครื่องมือจะมีบั๊ก แต่ผลการตรวจสอบไม่พบบั๊กประเภทที่สามารถนำไปใช้โจมตีเพื่ออ่านหน่วยความจำตามอำเภอใจได้
- ในช่วงไม่กี่ปีที่ผ่านมา GNU coreutils มี CVE ตระกูลความปลอดภัยของหน่วยความจำ ออกมาอย่างต่อเนื่อง
pwddeep path buffer overflownumfmtout-of-bounds readunexpand --tabsheap buffer overflowod --strings -Nการเขียน NUL นอก heap buffersortอ่าน 1 ไบต์ก่อนหน้า heap buffersplit --line-bytesเป็น heap overwrite ใน CVE-2024-0684b2sum --checkอ่านหน่วยความจำที่ไม่ได้จัดสรรจาก malformed inputtail -fstack buffer overrun
- เมื่อเทียบช่วงเวลาเดียวกัน งานเขียนใหม่ด้วย Rust รักษาจำนวนบั๊กหมวดนี้ไว้ที่ 0 รายการ
- อย่างไรก็ตาม ก็มีข้อแม้ด้วยว่าการตรวจสอบนี้ไม่ได้พิสูจน์ว่าไม่มีบั๊กด้านความปลอดภัยของหน่วยความจำ เพียงแต่ยังไม่พบเท่านั้น
- ปัญหาที่เหลืออยู่ส่วนใหญ่เกิดที่ ขอบเขตที่สัมผัสโลกภายนอก มากกว่าภายใน Rust
- พาธ
- ไบต์กับสตริง
- syscall
- ช่วงเวลาคั่นและการเปลี่ยนแปลงสถานะของไฟล์ซิสเต็ม
Rust ที่ถูกต้องก็คือ Rust ที่เป็นธรรมเนียมปฏิบัติที่ดีด้วย
- Rust แบบที่เป็นธรรมเนียมปฏิบัติที่ดี ไม่ได้หมายถึงแค่โค้ดที่ผ่าน borrow checker และไม่มีเสียงเตือนจาก
clippy - ความถูกต้อง เองก็ควรเป็นส่วนหนึ่งของความเป็นธรรมเนียมปฏิบัติที่ดี
- เพราะรูปแบบโค้ดที่อยู่รอดได้ในโลกจริงคือสิ่งที่ถูกตกผลึกขึ้นจากประสบการณ์ของชุมชน
- ระบบที่แข็งแรงไม่ควรซ่อนความสกปรกของโลกจริง แต่ควร สะท้อนมันออกมาตรง ๆ
- file descriptor แทนพาธ
OsStrแทนString?แทนunwrap- ความเข้ากันได้แบบ bug-for-bug กับต้นฉบับ แทนความหมายที่ดูสะอาดกว่า
- ระบบชนิดข้อมูลอาจอธิบายได้หลายอย่าง แต่ไม่สามารถครอบคลุมเงื่อนไขที่อยู่นอกการควบคุม เช่น เวลาที่ผ่านไประหว่าง syscall สองครั้ง
- Rust ที่เป็นธรรมเนียมปฏิบัติที่ดี ควรทำให้ชนิดข้อมูล ชื่อ และ control flow ของโค้ด เผยความจริงของสภาพแวดล้อมที่มันรันอยู่
- แม้จะสวยน้อยกว่าโค้ดบนไวท์บอร์ด แต่ก็ต้องซื่อสัตย์ต่อความจริงมากกว่า
เอกสารอ้างอิง
- An update on rust-coreutils: เผยแพร่ผลการตรวจสอบ
- Patterns for Defensive Programming in Rust: รูปแบบ Rust เชิงป้องกันที่อ่านคู่กันได้
- Pitfalls of Safe Rust: ความผิดพลาดที่พบบ่อยซึ่งเกิดได้แม้ใน safe Rust
- Sharp Edges In The Rust Standard Library: พฤติกรรมที่คาดไม่ถึงของ
std - uutils/coreutils on GitHub: GNU coreutils ที่นำมาเขียนใหม่ด้วย Rust
1 ความคิดเห็น
ความเห็นใน Hacker News
ในฐานะผู้ดูแลรักษา GNU Coreutils ฉันอ่านบทความนี้อย่างสนใจ แต่จาก Rust ที่ฉันเคยลองใช้มาบ้าง มันง่ายเกินไปที่จะสร้าง TOCTOU race ด้วย
std::fsหวังว่าสุดท้ายแล้ว API คล้าย
openatจะเข้ามาอยู่ใน standard libraryและฉันไม่เห็นด้วยกับกฎที่ว่า ให้ resolve path ก่อนค่อยเปรียบเทียบ
โดยทั่วไปการเรียก
fstatแล้วเปรียบเทียบst_devกับst_inoจะดีกว่า ซึ่งในบทความก็พูดถึงจุดนี้อยู่บ้างแล้วผลข้างเคียงที่คนพูดถึงกันน้อยกว่าคือ ต้นทุนด้านประสิทธิภาพ
ตัวอย่างจริงคือบน path ของไดเรกทอรีที่ลึกมาก
cpใช้เวลา 0.010 วินาที แต่uu_cpใช้เวลา 12.857 วินาทีในโลกจริงอาจไม่ค่อยมีใครจงใจสร้าง path แบบนี้ แต่ซอฟต์แวร์ของ GNU พยายามอย่างมากที่จะหลีกเลี่ยงข้อจำกัดที่ตั้งขึ้นตามอำเภอใจ
https://www.gnu.org/prep/standards/standards.html#Semantics
แล้วในบทความที่บอกว่าการ rewrite ด้วย Rust มี บั๊กด้าน memory safety เป็น 0 ในช่วงเวลาใกล้เคียงกัน อันนั้นไม่จริง :)
https://github.com/advisories/GHSA-w9vv-q986-vj7x
ใช่เลย
std::fsมีปัญหาแบบ lowest common denominator อยู่ตอน Rust 1.0 ต้องใส่อะไรสักอย่างเข้ามา และน่าเสียดายที่สภาพนั้นแข็งตัวอยู่นานมาก
ฉันคิดว่า
uutilsเป็นที่ที่เหมาะสำหรับลองออกแบบ API มาแทน std::fs ที่ทำพลาดได้ยากกว่าขอบคุณที่อธิบายมุมมองฝั่งตรงข้ามได้กระชับขนาดนี้
ฉันอยากถามว่าควรเรียนรู้อะไรจากเรื่องนี้
สำหรับโพสต์บนอินเทอร์เน็ตถือว่าจงใจถามแบบค่อนข้างแรง เพราะถ้ามีการเปรียบเทียบชัด ๆ มันจะช่วยให้เห็นความต่างและความผิดพลาดได้ชัดขึ้น
แน่นอนว่าไม่มีภาระใด ๆ ที่คุณต้องสละเวลาหรือพลังใจมาตอบ
ฉันสงสัยว่าทำไมเรื่อง ความเร็ว, ประสิทธิภาพ, race condition,
st_inoถึงมักมาคู่กันlatency, การเขียนลง storage จริง, atomicity, ACID, ความเร็วอันจำกัดของการส่งผ่านข้อมูล ดูเหมือนสุดท้ายจะลู่เข้าหาแก่นเดียวกัน
ระบบที่ต้องเชื่อถือได้สูงอย่างงานบัญชีดูเหมือนสุดท้ายก็ต้องลงเอยที่ ACID ส่วนระบบที่เชื่อถือได้น้อยกว่าก็อาจถูกลืมเร็วเกินไปจนทำให้รู้สึกว่าความต่างของคอมพิวเตอร์ไม่ค่อยสำคัญ
อีกอย่าง ฉันก็สงสัยว่าในแอปพลิเคชันทั่วไป throughput สำคัญกว่า latency จริงหรือเปล่า
แล้วแม้จะเข้าใจว่าการโฟกัสที่เลข inode มาจากประวัติของ C, Unix-like OS และ GNU coreutils
แต่ก็อยากรู้ว่าจะมองปัญหาง่าย ๆ อย่างการทำให้ USB drive ใช้เก็บไฟล์ได้แบบใช้งานได้จริงอย่างไร
โดยไม่เลี่ยงความซับซ้อนอย่าง
libcI/O buffering,fflush, kernel buffering, multicore, time-sharing, และการรันหลายแอปพร้อมกันฉันเป็นมือใหม่มาก ๆ เลยสงสัยว่าทำไมไม่
cdตรง ๆ ด้วย$(yes a/ | head -n $((32 * 1024)) | tr -d '\n')แต่ต้องใช้ลูปwhileแก้ไข: เข้าใจแล้ว เป็นเพราะ
-bash: cd: a/a/a/....../a/a/: File name too longไม่แน่ใจว่าคุณเคยเห็นหรือยัง แต่มีเดโมแปลง GNU utility อย่าง
wgetอัตโนมัติไปเป็น C++ subset ที่ปลอดภัยต่อหน่วยความจำhttps://duneroadrunner.github.io/scpp_articles/PoC_autotranslation_of_wget
แนวทางคือแทนที่ส่วน C ที่อันตรายด้วยส่วน C++ ที่ปลอดภัยและมีพฤติกรรมสอดคล้องกันแทบ 1:1 ดังนั้นจึงดูมีโอกาสน้อยกว่าที่จะนำบั๊กใหม่หรือความต่างของพฤติกรรมใหม่เข้ามาแบบการ rewrite
ถ้าจัดระเบียบโค้ดต้นฉบับนิดหน่อย การแปลงสามารถทำอัตโนมัติได้เต็มรูปแบบ จึงสามารถสร้าง executable ที่ช้าลงเล็กน้อยแต่ปลอดภัยต่อหน่วยความจำจากซอร์ส C เดิมได้ในขั้นตอน build
อาจเป็นคำถามงี่เง่านิดหน่อย แต่สงสัยว่าฝั่ง GNU Coreutils เคยพิจารณาหรือมีแผนทำ Rust rewrite ของตัวเองหรือไม่
พวกเขาอาจรู้วิธีใช้ Rust แต่ไม่ได้คุ้นเคยกับ Unix API รวมถึงความหมายเชิง semantics และกับดักของมันมากพอ
ความผิดพลาดส่วนใหญ่พวกนั้น ถ้ามองจากสายตานักพัฒนารุ่นเก่าฝั่ง GNU coreutils, BSD หรือ Solaris ถือว่าค่อนข้างระดับมือใหม่
ปัญหาแบบนี้จำนวนมากถูกค้นพบและจัดการไปแล้วตั้งแต่หลายสิบปีก่อน และแม้ใน codebase เดิมยังมีงานแก้แบบ long tail อยู่ แต่ตอนนี้ก็เหลือเพียงปริมาณเล็กน้อยที่ยังค่อย ๆ เพิ่มเข้ามา
ฉันอ่าน เธรดของ Canonical นั้นแล้วอึ้งมาก
ใจความประมาณว่า “Rust ปลอดภัยกว่า และความปลอดภัยคือสิ่งสำคัญที่สุด ดังนั้นการปล่อย rewrite ของ coreutils ทั้งชุดจึงเป็นเรื่องเร่งด่วน ถ้ามีอะไรพังก็ไม่เป็นไร ค่อยแก้ทีหลัง”
ฉันไม่อยากรันโค้ดที่เขียนโดยคนที่คิดแบบนั้นบนเครื่องของตัวเอง
ฉันเองก็สนับสนุน Rust แต่การที่ Rust ปลอดภัยกว่านั้นจริงได้ก็ ก็ต่อเมื่อเงื่อนไขอื่นเท่ากัน
ซึ่งในกรณีนี้เงื่อนไขอื่นไม่ได้เท่ากันเลย
การ rewrite ย่อมต้องมี บั๊กและช่องโหว่มากกว่าอย่างหลีกเลี่ยงไม่ได้ เมื่อเทียบกับโค้ดที่ดูแลมาหลายสิบปี ดังนั้นเหตุผลด้านความปลอดภัยอาจมีความหมายสำหรับกลยุทธ์เปลี่ยนผ่านระยะยาว แต่ไม่ใช่เหตุผลสำหรับการ rollout แบบรีบร้อน
การลดทอนผลกระทบต่อผู้ใช้หลังปล่อยจริงว่าเป็นเรื่องเล็กน้อย หรือพูดว่า “ต้องทำแบบนี้แหละบั๊กถึงจะโผล่” หรือ “coreutils เดิมก็ไม่มีการทดสอบที่ดีเหมือนกัน” เป็นท่าทีที่ไร้ความรับผิดชอบมาก
ผู้ใช้ไม่ใช่หนูทดลอง
ฉันคิดว่าผู้ดูแลรักษามี ความรับผิดชอบทางศีลธรรม ที่จะไม่ทำลายความน่าเชื่อถือของระบบผู้ใช้
ในระดับที่ลึกกว่านั้น ดูเหมือนว่า standard library ของ Rust จะชี้นำให้นักพัฒนาใช้ API ที่ดูสะอาด แต่ อยู่ในระดับ abstraction ที่ผิด
เช่นเอนเอียงไปทางการทำงานกับไฟล์แบบอิง path มากกว่าแบบอิง handle
หวังว่าฉันจะคิดผิด
ฉันมองว่าจุดสำคัญของ Rust คือทำให้กับดักที่ใหญ่ที่สุดและตกได้ง่ายที่สุด ไม่ต้องคอยกังวลมาก
แก่นของบทความนี้ก็ดูเหมือนจะเป็นว่า API ของ filesystem เองก็ควรทำหน้าที่แบบนั้น
มีคนเคยใช้คำว่า disassembler rage กับเรื่องลักษณะนี้
หมายถึงถ้าคุณจ้องดูอะไรใกล้พอ ความผิดพลาดทุกอย่างก็ดูเหมือนฝีมือสมัครเล่นได้
คำนี้ยังมาจากท่าทีแบบคนที่ดูแต่ disassembler แล้วด่านักพัฒนาระดับสูงว่าทำไมในฟังก์ชันที่อยู่ลึกลงไป 100 เฟรมของ call stack ถึงใช้
ifแทนswitchตอนนี้เรากำลังเห็นเพียงไม่กี่จุดที่พวกเขาทำผิด และแทบไม่ได้เห็น โค้ดอีกหลายพันบรรทัดที่เขียนถูกต้อง รอบ ๆ มันเลย
การปล่อยให้ utility แบบนี้เกิด
panicขึ้นถือว่าเป็นความผิดพลาดที่ สมัครเล่นมาก แม้จะวัดตามมาตรฐานของ Rust เองก็ตามถ้าเป็น alloc error ที่จัดการไม่ได้ก็ว่าไปอย่าง แต่
expectกับunwrapนั้นยากจะหาข้อแก้ตัวได้ เว้นแต่จะรับประกัน invariant อย่างเข้มงวดจริง ๆ ว่า code path นั้นจะไม่มีทางเกิดขึ้นหนึ่งในความยากของการ rewrite โค้ดคือ โค้ดเดิมผ่านการ ค่อย ๆ กลายรูป มาเรื่อย ๆ เพื่อตอบสนองต่อปัญหาที่เผยตัวออกมาเฉพาะในสภาพแวดล้อมใช้งานจริง
บทเรียนที่ได้จากกระบวนการนั้นค่อย ๆ ซึมเข้าไปในโค้ดอย่างเงียบ ๆ และถ้าไม่ได้ถูกบันทึกไว้ งาน เบื้องหลังที่ซ่อนอยู่ ที่ต้องทำก่อนจะไปถึงระดับเดียวกันก็จะมหาศาลมาก
บทความต้นฉบับแสดงรายการปัญหาประเภทนั้นได้ดีมาก
แต่ก่อนจะรีบเรียกมันว่ามือสมัครเล่น ก็ควรเห็นด้วยว่านี่เป็นหนึ่งในปรากฏการณ์ที่ “เป็นซอฟต์แวร์ที่สุด” ของวงการนี้
ถ้าไม่ใช่ว่ามีเอกสารทางเทคนิคดีมากและมีเทสต์ครอบคลุมเคสเหล่านั้นอยู่แล้วแต่ถูกมองข้าม เรื่องแบบนี้ก็แทบจะหลีกเลี่ยงไม่ได้
ตัวอย่างที่ดีในบทความคือ CVE เรื่อง chroot + NSS
กฎที่ว่า NSS เป็นแบบ dynamic และจะ
dlopenไลบรารีภายในchrootไม่ได้ถูกเขียนไว้เด่นชัดที่ไหนเลยมันใกล้เคียงกับสิ่งที่ sysadmin เรียนรู้กันจากประสบการณ์จริงตลอดกว่า 25 ปี และการ rewrite แบบ clean-room ก็มักจะกลับมาเรียนรู้สิ่งนี้ใหม่อีกครั้งในรูปของ CVE ใหม่
ต่อให้พอร์ตโค้ดเดิมด้วย LLM ก็ไม่ต่างกันมาก
คุณอ่าน function signature ได้ แต่สิ่งที่จำเป็นจริง ๆ คือ รอยแผลและรอยช้ำ ที่หลงเหลืออยู่ในโค้ดนั้น
ถ้าทำงานนี้โดยไม่อ่านแม้แต่ซอร์สต้นฉบับเพื่อหลีกเลี่ยง GPL มันก็ยิ่งยากขึ้นไปอีก
ฉันคิดว่า
uutilsคงจะดีกว่านี้มาก ถ้ามันเป็น GPL และได้รับอนุญาตให้ได้แรงบันดาลใจโดยตรงจาก ซอร์ส coreutils ต้นฉบับและควรพูดให้ชัดว่าการ ไม่บันทึกบทเรียนเหล่านี้ หรืออย่างน้อยไม่บันทึกบั๊กและช่องโหว่ที่พยายามหลีกเลี่ยง ก็เป็นแนวปฏิบัติที่ไม่ดีเช่นกัน
แน่นอนว่าการจะเขียนเอกสารครอบคลุมบั๊กทุกตัวที่ถูกหลีกเลี่ยงไว้โดยนัยจากการเขียนโค้ดที่ดีตั้งแต่ต้นนั้นทำได้ยาก แต่
สำหรับผู้อ่านในอนาคต มันสำคัญกว่าที่จะทิ้งคำอธิบายไว้ว่า “เหตุผลที่ตรงนี้ใช้
fooแทนbarก็เพราะถ้าใช้barภายใต้เงื่อนไข ABC จะเกิดbazที่อันตรายเนื่องจาก XYZ”ถึงจะดูเหมือนสิ้นเปลืองเวลาและพื้นที่เอกสารไปบ้างก็คุ้มกว่า
หลายจุดที่บทความนี้ชี้ให้เห็น โดยเฉพาะเมื่อเทียบกับ ซอร์ส GNU coreutils รู้สึกว่าอย่างน้อยควรถูกจับได้จาก unit test หรือการรีวิวด้วยมือ
การ rewrite coreutils ดูเป็นความคิดที่แย่มาก
https://www.joelonsoftware.com/2000/04/06/things-you-should-never-do-part-i/
และดูเหมือนมันถูกดำเนินไปแบบผิดทางโดยไม่ได้ยกเอาความรู้ที่สั่งสมอยู่ในซอฟต์แวร์เดิมมาให้เพียงพอ
ถ้าจะ rewrite คุณต้องเข้าใจและเรียนรู้จากงานเดิมให้หมดก่อน
ไม่อย่างนั้นก็จะทำผิดซ้ำเดิม และพูดตรง ๆ ว่ามันค่อนข้างน่าอาย
เพื่อความชัดเจน ฉันชอบ Rust ใช้มันในหลายโปรเจกต์ และมันยอดเยี่ยม
แค่ Rust ไม่ได้ช่วยคุณให้พ้นจาก วิศวกรรมที่แย่
ที่น่าสนใจคือ
uutilsใช้ ชุดทดสอบของ GNU coreutilsและเพิ่มเติมว่าพวกเขาก็ประกาศชัดเจนด้วยว่าจะไม่รับ contribution ที่เขียนขึ้นจากการอ่านซอร์ส GPL
ถ้ามาจากฝั่งที่เคยทำ
unity,upstart,snapเรื่องแบบนี้ก็ถือว่าอยู่ในขอบเขตที่คาดได้ดูเหมือนควรใช้คำทักทายต้อนรับนัก system programmer รุ่นใหม่ว่า
Unix มันพังอยู่แล้ว, สุดท้ายคุณต้องเขียนวิธีอ้อมที่ทั้งน่าเกลียดและไม่ค่อยสอนอะไรด้วยตัวเอง และยังต้องทำ การทดสอบเชิงประจักษ์
ซอฟต์แวร์ที่เชื่อถือได้และวิศวกรรมซอฟต์แวร์ที่ดีแต่เดิมก็ทำงานกันแบบนี้
ฉันสงสัยว่าทำไม differential fuzzing ถึงจับบั๊กพวกนี้ไม่ได้
https://github.com/uutils/coreutils/tree/main/fuzz/uufuzz
รูปแบบที่ตรวจสอบ path ด้วย syscall ครั้งหนึ่ง แล้วส่ง syscall ไปที่ path เดิมอีกครั้งเพื่อทำงาน จะนำปัญหาแบบเดิมกลับมาเสมอ
ผู้โจมตีที่มีสิทธิ์เขียนใน parent directory สามารถสลับองค์ประกอบของ path ให้กลายเป็น symbolic link ในช่วงระหว่างนั้นได้ และ kernel จะ resolve path ใหม่ตั้งแต่ต้นในคำสั่งเรียกครั้งที่สอง ทำให้การกระทำที่มีสิทธิ์สูงไปตกกับเป้าหมายที่ผู้โจมตีเลือก
ผู้โจมตีที่เขียน parent directory ได้ยังเล่นงานด้วย hard link ได้ด้วย
ต่อให้แตะได้เฉพาะไฟล์ธรรมดา ในทางปฏิบัติก็แทบไม่มีวิธีบรรเทาที่ดีนัก
ดูตัวอย่างได้ที่ https://michael.orlitzky.com/articles/posix_hardlink_heartache.xhtml
ต้นตอของบั๊ก หลายตัวดูเหมือนจะอยู่ที่ Unix API ทึบแสงเกินไป
ตัวอย่างเช่น
get_user_by_nameที่ไปโหลด shared library ภายใน root filesystem ใหม่เพื่อ resolve ชื่อผู้ใช้ แล้วทำให้ผู้โจมตีที่วางไฟล์ในchrootได้สามารถรันโค้ดด้วย uid 0 ได้ มันให้ความรู้สึกเกือบเหมือน กับระเบิดฟังก์ชันดึงข้อมูลผู้ใช้ที่จู่ ๆ ไปโหลด shared library เพิ่มด้วย ดูเหมือนเป็นการออกแบบที่ปะปนหลาย concerns
การค้นข้อมูลผู้ใช้กับการโหลดไลบรารีควรถูกแยกจากกันในระดับฟังก์ชัน หรืออย่างน้อยชื่อก็ควรบอกพฤติกรรมนั้นให้ชัด
บางส่วนอาจจริง แต่ถ้าตัดสินใจ rewrite coreutils ใหม่จากศูนย์ การ ทำความเข้าใจ POSIX API ก็คืองานหลักแบบตามตัวอักษรอยู่แล้ว
ยิ่งไปกว่านั้น ถ้าโค้ดที่ตรวจว่ามี path ชี้ไปยัง root ของ filesystem หรือไม่ เขียนว่า
file == Path::new("/")นั่นไม่ใช่ปัญหาของ APIคนที่เขียนแบบนั้นแทบไม่มีคุณสมบัติพอจะเข้าร่วมโปรเจกต์นี้เลย
ตรงกันข้าม ฉันคิดว่าการใช้ ภาษาเชิงฟังก์ชันที่ปลอดภัย อาจทำให้คนเผลอคิดว่าข้อมูลที่กำลังจัดการอยู่นั้นไม่มี state ไปด้วย
แต่ในระบบปฏิบัติการมีหลายอย่างมากที่เปลี่ยนอยู่ตลอดเวลา
จนกว่าจะมี filesystem ที่ให้ snapshot ได้ คุณก็ต้อง ตรวจซ้ำอยู่เรื่อย ๆ
สุดท้าย API ที่ต้องการจริง ๆ คือ API ที่เมื่อรับ input แล้วให้ผลลัพธ์เป็นสำเร็จหรือไม่ก็ล้มเหลวเท่านั้น
ไม่ใช่ API ที่ให้หนึ่งในสามอย่างคือ success, failure, error
ใช่
musl libcตัดส่วนแบบนั้นออกไปหนึ่งจุดพอดีฉันคิดว่าต้นตอจริง ๆ ไม่ใช่ความทึบของ Unix API แต่เป็นการที่ root ไป chroot เข้าไดเรกทอรีที่ตัวเองควบคุมไม่ได้ โดยไม่ได้คิดให้รอบด้าน
อะไรก็ตามที่อยู่ในเป้าหมายของ
chrootนั้นอยู่ภายใต้การควบคุมของฝั่งที่สร้างมันขึ้นมา และถ้าไม่เข้าใจจุดนี้ก็ไม่ควรใช้chroot()get_user_by_nameอาจให้ความรู้สึกเหมือนเป็นกับดัก แต่จริง ๆ แล้วแทบไม่มีความต่างเชิงสาระระหว่างการใช้newroot/etc/passwdกับการใช้newroot/usr/lib/x86_64-linux-gnu/libnss_compat.so,newroot/bin/shอะไรทำนองนั้นดังนั้นฉันคิดว่า
/usr/sbin/chrootไม่ควรมีเหตุผลอะไรเลยที่จะต้อง lookup user IDtoybox chrootก็ไม่ทำแบบนั้นสุดท้ายบั๊กจึงไม่ได้อยู่ที่การทำบางอย่างผิดวิธี แต่คือ การไปทำสิ่งนั้นตั้งแต่แรก
Unix และ POSIX เต็มไปด้วยกับดักแบบแฟรกทัล ตัดตรงไหนก็เจอ
ต่อให้สมมติว่าคนฝั่ง Rust เขียน coreutils ใหม่โดยไม่มีประสบการณ์ Linux ฉันก็ยังไม่เข้าใจยิ่งกว่าว่า Ubuntu รับมันเข้า mainline ได้อย่างไร
Ubuntu ดูเหมือนมีนโยบายที่จะเปลี่ยนองค์ประกอบพื้นฐานของระบบสักอย่างหนึ่งในแทบทุกรีลีสให้กลายเป็น การทดลองที่ลวก ๆ และยังไม่เสร็จ
ประเด็นสำคัญตรงนี้ไม่ใช่ “โอ้โห โค้ด Rust มีบั๊กด้วยเหรอ” แต่คือเรื่องนั้นต่างหาก
ของเดิมเป็น สัญญาอนุญาต GPL ส่วนเวอร์ชัน rewrite เป็น สัญญาอนุญาต MIT
ถ้าประโยคที่ว่า “บั๊กเหล่านี้มาจากโค้ด Rust ที่ถูกปล่อยใช้งานจริง และคนเขียนก็รู้ว่าตัวเองกำลังทำอะไรอยู่” เป็นความจริง
ก็ทำให้ฉันสงสัยว่า utility ต้นฉบับไม่มี test harness เลยหรือ และฝั่ง rewrite ก็ไม่ได้เริ่มจากการสร้างสิ่งนั้นก่อนใช่ไหม
ต่อให้มี edge case เยอะ ฉันก็ยังคิดว่าน่าจะ abstraction OS กับ FS ได้ระดับหนึ่งเพื่อเช็กอะไรอย่าง
rm .//ว่าจะไม่ลบไดเรกทอรีปัจจุบันจริง ๆ ตามที่คาดหรือไม่นี่ดูไม่ใช่ปัญหาของการเขียนโค้ดสกปรกหรือข้อวิจารณ์ตัวภาษา แต่เหมือนเป็นทัศนคติเก่า ๆ แบบ system programming ไม่ต้องทดสอบ อีกครั้งหนึ่ง
ในทางกลับกัน ถ้ายูทิลิตีต้นฉบับมีการทดสอบอยู่แล้วแต่ยังมีช่องโหว่มากขนาดนี้ ก็อาจแปลว่าชุดทดสอบของต้นฉบับเองมีข้อบกพร่องใหญ่พอสมควร
ฉันก็คิดแบบนั้น
แต่ฉันไม่มั่นใจเท่าไรนักกับประเด็นที่ว่าคุณจะ abstraction OS และ FS ได้พอสำหรับการตรวจสอบ
เพราะดูเหมือนผู้คนพยายามทำเรื่องนั้นมาตั้งแต่ก่อนฉันเกิด แต่ก็ยังไม่สำเร็จ
ยกตัวอย่างแค่ว่าจะเลือกทดสอบ
/ซ้ำกี่ตัวก็ยังไม่มีคำตอบชัดเจนยิ่งไปกว่านั้น ถ้าสมมติว่า
rmจะปฏิเสธการลบไฟล์เมื่อ 9 ไบต์แรกของไฟล์คือimportantถ้าคุณไม่รู้สตริงนั้นล่วงหน้า จะคิดชุดทดสอบอย่างไรให้จับพฤติกรรมแบบนั้นได้ก็น่าปวดหัว
ยิ่งถ้าคำนั้นเป็นสตริงที่ไม่มีในพจนานุกรมก็ยิ่งยากกว่าเดิม
ฉันแทบไม่เคยเห็นใครพูดอย่างจริงจังว่า “system programming ไม่ต้องทดสอบ”
แต่ฉันได้ยินบ่อยกว่าว่าการทดสอบ ไม่ได้ทำหน้าที่อย่างที่คนคาดหวังเสมอไป
เท่าที่ฉันเข้าใจ ระหว่างการพัฒนา
uutilsมี การทดสอบเปรียบเทียบพฤติกรรมอย่างกว้างขวาง กับ utility ต้นฉบับ และถึงขั้นพยายามคงบั๊กเดิมไว้ด้วยนี่เป็นเหตุผลหนึ่งที่ Windows ปิด symlink ไว้ตามค่าปริยาย
ไม่ได้แก้ด้วย abstraction แต่แก้ด้วยการแทบจะเอาฟีเจอร์นั้นออกไปเลย
ฝั่ง Unix-like ทำแบบนั้นไม่ได้ เพราะมีซอฟต์แวร์ที่พึ่งพา symlink มาหลายสิบปีมากเกินไป
MacOS ก็มีวิธีรับมือคล้ายกัน
ตัวอย่างเช่นบั๊ก
chroot()มักไม่ใช่ปัญหาจริงในค่าตั้งต้น เพราะ MacOS บล็อกchroot()เป็นค่าเริ่มต้นถ้าจะใช้ต้องปิด system integrity protection
ปัญหาเชิงรากอยู่ที่ มุมแหลมคมของ POSIX API และทางแก้ก็ไม่ใช่การ abstraction มัน แต่ใกล้เคียงกับการ ตัดมันทิ้งไปเลย
ฉันโอเคกับการที่ผู้คนจะทดลองและทำพลาดแบบมือใหม่
ก็คนเราเรียนรู้และเติบโตกันแบบนั้น
สิ่งที่ฉันสงสัยจริง ๆ คือห่วงโซ่การตัดสินใจของ Ubuntu มันพังตรงไหนถึงได้ปล่อยของแบบนี้เข้า production