לדלג לתוכן

פעולות נתיב עמידות לשינויים מקבילים

נניח ששרת קבצים בודק שנתיב נמצא בתוך תיקיית עבודה, ואז פותח אותו. בין הבדיקה לפתיחה תהליך אחר יכול להחליף רכיב בקישור סמלי. הבדיקה הצליחה על מצב אחד, והפתיחה השתמשה במצב אחר. הבעיה אינה נפתרת רק על ידי נרמול המחרוזת, משום שסביבת השמות עצמה יכולה להשתנות.

הממשק openat מאפשר להתחיל פתרון נתיב מתוך מתאר ספרייה במקום להסתמך על current working directory משותפת של התהליך. זאת נקודת בסיס יציבה יותר, אבל היא אינה לבדה איסור על קישורים, .. או מעבר mount. צריך לבחור את כללי החיפוש לפי ההבטחה שהמוצר רוצה לתת.

בלינוקס הממשק openat2 מאפשר לבקש אילוצי resolution כחלק מאותה פעולה. נבנה עטיפה שמקבלת ספריית בסיס ונתיב יחסי, ודורשת שהחיפוש לא יברח מעל הבסיס ולא יעבור דרך קישורים סמליים. הדוגמה דורשת libc וקרנל שמציע את הממשק בסביבה הנוכחית.

use std::{ffi::CString, fs::File, io, path::Path};
use std::os::fd::{AsFd, AsRawFd, BorrowedFd, FromRawFd};
use std::os::unix::ffi::OsStrExt;

#[repr(C)]
struct OpenHow { flags: u64, mode: u64, resolve: u64 }
fn open_beneath(directory: BorrowedFd<'_>, path: &Path) -> io::Result<File> {
    const RESOLVE_NO_SYMLINKS: u64 = 0x04;
    const RESOLVE_BENEATH: u64 = 0x08;
    let path = CString::new(path.as_os_str().as_bytes())
        .map_err(|_| io::Error::new(io::ErrorKind::InvalidInput, "NUL in path"))?;
    let how = OpenHow {
        flags: (libc::O_RDONLY | libc::O_CLOEXEC) as u64,
        mode: 0,
        resolve: RESOLVE_BENEATH | RESOLVE_NO_SYMLINKS,
    };
    let fd = unsafe { libc::syscall(libc::SYS_openat2, directory.as_raw_fd(),
        path.as_ptr(), &how as *const OpenHow, std::mem::size_of::<OpenHow>()) };
    if fd == -1 { Err(io::Error::last_os_error()) }
    else { Ok(unsafe { File::from_raw_fd(fd as i32) }) }
}
fn main() -> io::Result<()> {
    let directory = File::open("workspace")?;
    let file = open_beneath(directory.as_fd(), Path::new("input/data.bin"))?;
    println!("opened bytes={}", file.metadata()?.len());
    Ok(())
}

צרו את workspace/input/data.bin בתיקיית תרגיל והריצו את הדוגמה. לאחר מכן החליפו את input בקישור סמלי בתוך הסביבה שלכם ובדקו שהפתיחה נדחית בהתאם לאילוץ. בדקו גם נתיב מוחלט וניסיון לצאת עם ... אין להסתפק בבדיקת קלט אחד שנראה חשוד.

העטיפה מחזירה שגיאה אם הפעולה אינה זמינה, ואינה מחליפה אותה בשקט ב־canonicalize ואחריו open. רצף כזה אינו מקיים את אותו חוזה מול שינוי מקביל. אם רוצים fallback, צריך לנסח ולבדוק פרוטוקול אחר, או לצמצם במפורש את ההבטחה למשתמש.

החוזה של RESOLVE_BENEATH אינו אומר שכל אובייקט שהתקבל נוצר בתוך עולם פרטי. קישור מסוג hard link יכול לקשר לאובייקט שמופיע גם בשם אחר, ו־mount יכול להציג מערכת קבצים נוספת בהתאם לאילוצים שבחרנו. אם רוצים לאסור מעבר mount, יש אילוץ נוסף עם מחיר תאימות מתאים. המוצר צריך להחליט אם bind mount מותר במקום להניח שהוא שקול לקישור סמלי.

מתאר ספרייה אינו צילום קפוא של כל התוכן שלה. שמות בתוכה עדיין יכולים להשתנות, והפעולה מחזירה את האובייקט שנפתר לפי כללי הקריאה. היתרון הוא שההגבלות נאכפות בתוך מסלול הפתרון, ולא באמצעות בדיקה חיצונית שנגמרת לפני השימוש.

שימו לב גם לבחירת ההרשאות של המתאר. פתיחת ספרייה לקריאה יכולה לדרוש הרשאות שונות ממתאר O_PATH שמשמש כעוגן לפתרון. כאשר נבנה שירות בעל הרשאות מצומצמות, נבחר את היכולת הדרושה ולא נפתח כל ספרייה בהרשאות רחבות רק כי זאת הדוגמה הקצרה ביותר.

בדיקת מרוץ צריכה להפעיל משנה שמות מקביל לאורך זמן ולסווג את התוצאות. מותר שהפעולה תיכשל כאשר הסביבה משתנה; אסור שהיא תצליח באובייקט שמפר את ההבטחה. כך מבחן התקינות אינו תלוי בכך שנצליח לגרום לכל פתיחה להסתיים, אלא בכך שכל הצלחה מקיימת את החוזה.

הפורמט וכללי ההרחבה של המבנה מתועדים בעמוד openat2. השתמשו בגודל המבנה המדויק ובשדות שמוגדרים עבור הגרסה שבחרתם, ואל תפרשו שגיאת דגלים כראיה לכך שהנתיב עצמו אינו קיים.