diff --git a/rust/helpers.c b/rust/helpers.c
index acff58e6caff..2db5df578df2 100644
--- a/rust/helpers.c
+++ b/rust/helpers.c
@@ -282,6 +282,12 @@ loff_t rust_helper_i_size_read(const struct inode *inode)
 }
 EXPORT_SYMBOL_GPL(rust_helper_i_size_read);
 
+void rust_helper_mapping_set_large_folios(struct address_space *mapping)
+{
+	mapping_set_large_folios(mapping);
+}
+EXPORT_SYMBOL_GPL(rust_helper_mapping_set_large_folios);
+
 unsigned long rust_helper_copy_to_user(void __user *to, const void *from,
 				       unsigned long n)
 {
diff --git a/rust/kernel/folio.rs b/rust/kernel/folio.rs
index 20f51db920e4..077328b733e4 100644
--- a/rust/kernel/folio.rs
+++ b/rust/kernel/folio.rs
@@ -49,7 +49,6 @@ impl<S> Folio<S> {
     /// Callers must ensure that:
     /// * `ptr` is valid and remains so for the lifetime of the returned reference.
     /// * The folio has the right state.
-    #[allow(dead_code)]
     pub(crate) unsafe fn from_raw<'a>(ptr: *mut bindings::folio) -> &'a Self {
         // SAFETY: The safety requirements guarantee that the cast below is ok.
         unsafe { &*ptr.cast::<Self>() }
diff --git a/rust/kernel/fs/address_space.rs b/rust/kernel/fs/address_space.rs
index 5b4fcb568f46..e539d690235b 100644
--- a/rust/kernel/fs/address_space.rs
+++ b/rust/kernel/fs/address_space.rs
@@ -6,8 +6,9 @@
 //!
 //! C headers: [`include/linux/fs.h`](srctree/include/linux/fs.h)
 
-use super::FileSystem;
-use crate::bindings;
+use super::{file::File, FileSystem};
+use crate::error::{from_result, Result};
+use crate::{bindings, folio::Folio, folio::PageCache, types::Locked};
 use core::marker::PhantomData;
 use macros::vtable;
 
@@ -16,10 +17,15 @@
 pub trait Operations {
     /// File system that these operations are compatible with.
     type FileSystem: FileSystem + ?Sized;
+
+    /// Reads the contents of the inode into the given folio.
+    fn read_folio(
+        file: Option<&File<Self::FileSystem>>,
+        folio: Locked<&Folio<PageCache<Self::FileSystem>>>,
+    ) -> Result;
 }
 
 /// Represents address space operations.
-#[allow(dead_code)]
 pub struct Ops<T: FileSystem + ?Sized>(
     pub(crate) *const bindings::address_space_operations,
     pub(crate) PhantomData<T>,
@@ -32,7 +38,11 @@ pub const fn new<U: Operations<FileSystem = T> + ?Sized>() -> Self {
         impl<T: Operations + ?Sized> Table<T> {
             const TABLE: bindings::address_space_operations = bindings::address_space_operations {
                 writepage: None,
-                read_folio: None,
+                read_folio: if T::HAS_READ_FOLIO {
+                    Some(Self::read_folio_callback)
+                } else {
+                    None
+                },
                 writepages: None,
                 dirty_folio: None,
                 readahead: None,
@@ -52,6 +62,28 @@ impl<T: Operations + ?Sized> Table<T> {
                 swap_deactivate: None,
                 swap_rw: None,
             };
+
+            extern "C" fn read_folio_callback(
+                file_ptr: *mut bindings::file,
+                folio_ptr: *mut bindings::folio,
+            ) -> i32 {
+                from_result(|| {
+                    let file = if file_ptr.is_null() {
+                        None
+                    } else {
+                        // SAFETY: The C API guarantees that `file_ptr` is a valid file if non-null.
+                        Some(unsafe { File::from_raw(file_ptr) })
+                    };
+
+                    // SAFETY: The C API guarantees that `folio_ptr` is a valid folio.
+                    let folio = unsafe { Folio::from_raw(folio_ptr) };
+
+                    // SAFETY: The C contract guarantees that the folio is valid and locked, with
+                    // ownership of the lock transferred to the callee (this function).
+                    T::read_folio(file, unsafe { Locked::new(folio) })?;
+                    Ok(0)
+                })
+            }
         }
         Self(&Table::<U>::TABLE, PhantomData)
     }
diff --git a/rust/kernel/fs/file.rs b/rust/kernel/fs/file.rs
index 2ba456a1eee1..0828676eae1c 100644
--- a/rust/kernel/fs/file.rs
+++ b/rust/kernel/fs/file.rs
@@ -355,6 +355,12 @@ fn read_dir(
 pub struct Ops<T: FileSystem + ?Sized>(pub(crate) *const bindings::file_operations, PhantomData<T>);
 
 impl<T: FileSystem + ?Sized> Ops<T> {
+    /// Returns file operations for page-cache-based ro files.
+    pub fn generic_ro_file() -> Self {
+        // SAFETY: This is a constant in C, it never changes.
+        Self(unsafe { &bindings::generic_ro_fops }, PhantomData)
+    }
+
     /// Creates file operations from a type that implements the [`Operations`] trait.
     pub const fn new<U: Operations<FileSystem = T> + ?Sized>() -> Self {
         struct Table<T: Operations + ?Sized>(PhantomData<T>);
@@ -516,6 +522,7 @@ impl From<inode::Type> for DirEntryType {
     fn from(value: inode::Type) -> Self {
         match value {
             inode::Type::Dir => DirEntryType::Dir,
+            inode::Type::Reg => DirEntryType::Reg,
         }
     }
 }
diff --git a/rust/kernel/fs/inode.rs b/rust/kernel/fs/inode.rs
index c314d036c87e..1a41c824d30d 100644
--- a/rust/kernel/fs/inode.rs
+++ b/rust/kernel/fs/inode.rs
@@ -6,7 +6,9 @@
 //!
 //! C headers: [`include/linux/fs.h`](srctree/include/linux/fs.h)
 
-use super::{dentry, dentry::DEntry, file, sb::SuperBlock, FileSystem, Offset, UnspecifiedFS};
+use super::{
+    address_space, dentry, dentry::DEntry, file, sb::SuperBlock, FileSystem, Offset, UnspecifiedFS,
+};
 use crate::error::{code::*, Result};
 use crate::types::{ARef, AlwaysRefCounted, Lockable, Locked, Opaque};
 use crate::{bindings, block, time::Timespec};
@@ -127,6 +129,11 @@ pub fn init(mut self, params: Params) -> Result<ARef<INode<T>>> {
         let inode = unsafe { self.0.as_mut() };
         let mode = match params.typ {
             Type::Dir => bindings::S_IFDIR,
+            Type::Reg => {
+                // SAFETY: The `i_mapping` pointer doesn't change and is valid.
+                unsafe { bindings::mapping_set_large_folios(inode.i_mapping) };
+                bindings::S_IFREG
+            }
         };
 
         inode.i_mode = (params.mode & 0o777) | u16::try_from(mode)?;
@@ -166,6 +173,14 @@ pub fn set_fops(&mut self, fops: file::Ops<T>) -> &mut Self {
         inode.__bindgen_anon_3.i_fop = fops.0;
         self
     }
+
+    /// Sets the address space operations on this new inode.
+    pub fn set_aops(&mut self, aops: address_space::Ops<T>) -> &mut Self {
+        // SAFETY: By the type invariants, it's ok to modify the inode.
+        let inode = unsafe { self.0.as_mut() };
+        inode.i_data.a_ops = aops.0;
+        self
+    }
 }
 
 impl<T: FileSystem + ?Sized> Drop for New<T> {
@@ -181,6 +196,9 @@ fn drop(&mut self) {
 pub enum Type {
     /// Directory type.
     Dir,
+
+    /// Regular file type.
+    Reg,
 }
 
 /// Required inode parameters.
diff --git a/samples/rust/rust_rofs.rs b/samples/rust/rust_rofs.rs
index 2a87e524e0e1..8005fd14b2e1 100644
--- a/samples/rust/rust_rofs.rs
+++ b/samples/rust/rust_rofs.rs
@@ -3,10 +3,11 @@
 //! Rust read-only file system sample.
 
 use kernel::fs::{
-    dentry, dentry::DEntry, file, file::File, inode, inode::INode, sb::SuperBlock, Offset,
+    address_space, dentry, dentry::DEntry, file, file::File, inode, inode::INode, sb, Offset,
 };
 use kernel::prelude::*;
-use kernel::{c_str, fs, time::UNIX_EPOCH, types::ARef, types::Either, types::Locked, user};
+use kernel::types::{ARef, Either, Locked};
+use kernel::{c_str, folio::Folio, folio::PageCache, fs, time::UNIX_EPOCH, user};
 
 kernel::module_fs! {
     type: RoFs,
@@ -20,6 +21,7 @@ struct Entry {
     name: &'static [u8],
     ino: u64,
     etype: inode::Type,
+    contents: &'static [u8],
 }
 
 const ENTRIES: [Entry; 3] = [
@@ -27,41 +29,53 @@ struct Entry {
         name: b".",
         ino: 1,
         etype: inode::Type::Dir,
+        contents: b"",
     },
     Entry {
         name: b"..",
         ino: 1,
         etype: inode::Type::Dir,
+        contents: b"",
     },
     Entry {
-        name: b"subdir",
+        name: b"test.txt",
         ino: 2,
-        etype: inode::Type::Dir,
+        etype: inode::Type::Reg,
+        contents: b"hello world\n",
     },
 ];
 
 const DIR_FOPS: file::Ops<RoFs> = file::Ops::new::<RoFs>();
 const DIR_IOPS: inode::Ops<RoFs> = inode::Ops::new::<RoFs>();
+const FILE_AOPS: address_space::Ops<RoFs> = address_space::Ops::new::<RoFs>();
 
 struct RoFs;
 
 impl RoFs {
-    fn iget(sb: &SuperBlock<Self>, e: &'static Entry) -> Result<ARef<INode<Self>>> {
+    fn iget(sb: &sb::SuperBlock<Self>, e: &'static Entry) -> Result<ARef<INode<Self>>> {
         let mut new = match sb.get_or_create_inode(e.ino)? {
             Either::Left(existing) => return Ok(existing),
             Either::Right(new) => new,
         };
 
-        match e.etype {
-            inode::Type::Dir => new.set_iops(DIR_IOPS).set_fops(DIR_FOPS),
+        let (mode, nlink, size) = match e.etype {
+            inode::Type::Dir => {
+                new.set_iops(DIR_IOPS).set_fops(DIR_FOPS);
+                (0o555, 2, ENTRIES.len().try_into()?)
+            }
+            inode::Type::Reg => {
+                new.set_fops(file::Ops::generic_ro_file())
+                    .set_aops(FILE_AOPS);
+                (0o444, 1, e.contents.len().try_into()?)
+            }
         };
 
         new.init(inode::Params {
             typ: e.etype,
-            mode: 0o555,
-            size: ENTRIES.len().try_into()?,
-            blocks: 1,
-            nlink: 2,
+            mode,
+            size,
+            blocks: (u64::try_from(size)? + 511) / 512,
+            nlink,
             uid: 0,
             gid: 0,
             atime: UNIX_EPOCH,
@@ -74,12 +88,12 @@ fn iget(sb: &SuperBlock<Self>, e: &'static Entry) -> Result<ARef<INode<Self>>> {
 impl fs::FileSystem for RoFs {
     const NAME: &'static CStr = c_str!("rust_rofs");
 
-    fn fill_super(sb: &mut SuperBlock<Self>) -> Result {
+    fn fill_super(sb: &mut sb::SuperBlock<Self>) -> Result {
         sb.set_magic(0x52555354);
         Ok(())
     }
 
-    fn init_root(sb: &SuperBlock<Self>) -> Result<dentry::Root<Self>> {
+    fn init_root(sb: &sb::SuperBlock<Self>) -> Result<dentry::Root<Self>> {
         let inode = Self::iget(sb, &ENTRIES[0])?;
         dentry::Root::try_new(inode)
     }
@@ -109,6 +123,33 @@ fn lookup(
     }
 }
 
+#[vtable]
+impl address_space::Operations for RoFs {
+    type FileSystem = Self;
+
+    fn read_folio(_: Option<&File<Self>>, mut folio: Locked<&Folio<PageCache<Self>>>) -> Result {
+        let data = match folio.inode().ino() {
+            2 => ENTRIES[2].contents,
+            _ => return Err(EINVAL),
+        };
+
+        let pos = usize::try_from(folio.pos()).unwrap_or(usize::MAX);
+        let copied = if pos >= data.len() {
+            0
+        } else {
+            let to_copy = core::cmp::min(data.len() - pos, folio.size());
+            folio.write(0, &data[pos..][..to_copy])?;
+            to_copy
+        };
+
+        folio.zero_out(copied, folio.size() - copied)?;
+        folio.mark_uptodate();
+        folio.flush_dcache();
+
+        Ok(())
+    }
+}
+
 #[vtable]
 impl file::Operations for RoFs {
     type FileSystem = Self;
