לדלג לתוכן

מערכת קבצים במרחב משתמש ובדיקות כשל

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

נבנה filesystem לקריאה בלבד עם קובץ קבוע אחד. הדוגמה מקובעת ל־fuser בגרסה 0.16.0, כדי שהחתימות יהיו ניתנות לבדיקה מול תיעוד מסוים. צרו פרויקט Cargo והשתמשו בתלויות הבאות, לצד שם החבילה ומהדורת 2024 הרגילים.

[dependencies]
fuser = { version = "=0.16.0", default-features = false, features = ["abi-7-31"] }
libc = "0.2"

הקוד הבא הוא src/main.rs מלא. הוא מחזיר תשובה לכל בקשה שהוא מממש, בודק offset ומכבד buffer מלא בעת רשימת ספרייה.

use std::{ffi::OsStr, time::{Duration, SystemTime}};
use fuser::{FileAttr, FileType, Filesystem, MountOption, Request,
    ReplyAttr, ReplyData, ReplyDirectory, ReplyEntry, ReplyOpen};
const CONTENT: &[u8] = b"hello from userspace\n";
const TTL: Duration = Duration::from_secs(1);
struct Demo;
fn attribute(ino: u64) -> Option<FileAttr> {
    let (kind, size, perm, nlink) = match ino {
        1 => (FileType::Directory, 0, 0o755, 2),
        2 => (FileType::RegularFile, CONTENT.len() as u64, 0o444, 1),
        _ => return None,
    };
    Some(FileAttr { ino, size, blocks: 0,
        atime: SystemTime::UNIX_EPOCH, mtime: SystemTime::UNIX_EPOCH,
        ctime: SystemTime::UNIX_EPOCH, crtime: SystemTime::UNIX_EPOCH,
        kind, perm, nlink, uid: unsafe { libc::getuid() },
        gid: unsafe { libc::getgid() }, rdev: 0, blksize: 4096, flags: 0 })
}
impl Filesystem for Demo {
    fn lookup(&mut self, _: &Request<'_>, parent: u64, name: &OsStr, reply: ReplyEntry) {
        if parent == 1 && name == OsStr::new("hello") {
            reply.entry(&TTL, &attribute(2).unwrap(), 0);
        } else { reply.error(libc::ENOENT); }
    }
    fn getattr(&mut self, _: &Request<'_>, ino: u64, _: Option<u64>, reply: ReplyAttr) {
        match attribute(ino) {
            Some(attr) => reply.attr(&TTL, &attr),
            None => reply.error(libc::ENOENT),
        }
    }
    fn open(&mut self, _: &Request<'_>, ino: u64, flags: i32, reply: ReplyOpen) {
        if ino != 2 { reply.error(libc::ENOENT); }
        else if flags & libc::O_ACCMODE != libc::O_RDONLY { reply.error(libc::EACCES); }
        else { reply.opened(0, 0); }
    }
    fn read(&mut self, _: &Request<'_>, ino: u64, _: u64, offset: i64, size: u32,
            _: i32, _: Option<u64>, reply: ReplyData) {
        if ino != 2 { reply.error(libc::ENOENT); return; }
        if offset < 0 { reply.error(libc::EINVAL); return; }
        let start = usize::try_from(offset).unwrap_or(usize::MAX);
        if start >= CONTENT.len() { reply.data(&[]); return; }
        let count = (size as usize).min(CONTENT.len() - start);
        reply.data(&CONTENT[start..start + count]);
    }
    fn readdir(&mut self, _: &Request<'_>, ino: u64, _: u64, offset: i64,
               mut reply: ReplyDirectory) {
        if ino != 1 { reply.error(libc::ENOTDIR); return; }
        if offset < 0 { reply.error(libc::EINVAL); return; }
        let entries = [(1, FileType::Directory, "."),
            (1, FileType::Directory, ".."), (2, FileType::RegularFile, "hello")];
        let start = usize::try_from(offset).unwrap_or(usize::MAX);
        for (index, &(number, kind, name)) in entries.iter().enumerate().skip(start) {
            if reply.add(number, (index + 1) as i64, kind, name) { break; }
        }
        reply.ok();
    }
}
fn main() -> std::io::Result<()> {
    let mountpoint = std::env::args_os().nth(1).ok_or_else(||
        std::io::Error::new(std::io::ErrorKind::InvalidInput, "mountpoint required"))?;
    fuser::mount2(Demo, mountpoint,
        &[MountOption::RO, MountOption::DefaultPermissions, MountOption::FSName("course-demo".into())])
}

צרו תיקיית mount ריקה בשם fuse-lab בתוך תיקיית התרגיל, והריצו cargo run -- fuse-lab במסוף אחד. במסוף אחר קראו את fuse-lab/hello ורשמו את תוכן הספרייה. ההרצה דורשת תמיכת FUSE, גישה ל־/dev/fuse וכלי mount מתאים בסביבה. בסיום נתקו רק את נקודת התרגיל באמצעות כלי fusermount או fusermount3 המתאים להתקנה.

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

המטמון משנה את מה שנראה בלוג. קריאה נוספת של אותו קובץ יכולה להיענות מתוך cache בלי להפעיל שוב את callback הקריאה שלנו. ה־TTL של attributes והמדיניות של נתוני קובץ הם חוזים נפרדים. הוסיפו לוג אבחוני לכל callback ובדקו אילו בקשות מגיעות בפועל, בלי להניח שכל cat חייב ליצור אותו רצף.

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

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

הארכיטקטורה מוסברת בתיעוד FUSE של הקרנל, והחתימות בדוגמה בתיעוד Filesystem בגרסה שנבחרה. כאשר משדרגים גרסה, נבדוק את ה־API ואת הפרוטוקול מחדש במקום להניח שהדוגמה היא חוזה בלתי משתנה של כל ספרייה.