1use std::{
4 collections::{HashMap, HashSet},
5 sync::{Arc, Mutex, MutexGuard},
6 time::{Duration, SystemTime},
7};
8
9use aster_forge_cloud_files_core::{
10 ByteRange, CloudFilesBackend, CloudItem, CloudItemKey, CloudItemKind, ContentReadRequest,
11 ContentRevision, PageCursor, SessionGeneration,
12};
13use bytes::Bytes;
14
15use crate::{
16 LINUX_ROOT_INODE, LinuxCloudFilesError, LinuxInode, LinuxInodeGeneration, LinuxInodeRecord,
17 LinuxInodeTable, Result,
18};
19
20const FUSE_BLOCK_SIZE: u64 = 512;
21
22fn lock<T>(mutex: &Mutex<T>) -> MutexGuard<'_, T> {
23 match mutex.lock() {
24 Ok(guard) => guard,
25 Err(poisoned) => poisoned.into_inner(),
26 }
27}
28
29pub fn validate_linux_name(name: &str) -> Result<()> {
35 if name.is_empty() {
36 return Err(LinuxCloudFilesError::InvalidName {
37 reason: "name must not be empty",
38 });
39 }
40 if name == "." || name == ".." {
41 return Err(LinuxCloudFilesError::InvalidName {
42 reason: "dot components are reserved",
43 });
44 }
45 if name.contains('/') {
46 return Err(LinuxCloudFilesError::InvalidName {
47 reason: "name must not contain a path separator",
48 });
49 }
50 if name.contains('\0') {
51 return Err(LinuxCloudFilesError::InvalidName {
52 reason: "name must not contain NUL",
53 });
54 }
55 Ok(())
56}
57
58#[derive(Debug, Clone, Copy, PartialEq, Eq)]
60pub enum LinuxNodeKind {
61 File,
63 Directory,
65}
66
67#[derive(Debug, Clone, PartialEq, Eq)]
69pub struct LinuxAttributePolicy {
70 uid: u32,
71 gid: u32,
72 file_permissions: u16,
73 directory_permissions: u16,
74 block_size: u32,
75 cache_ttl: Duration,
76 fallback_time: SystemTime,
77}
78
79impl LinuxAttributePolicy {
80 pub fn new(
86 uid: u32,
87 gid: u32,
88 file_permissions: u16,
89 directory_permissions: u16,
90 cache_ttl: Duration,
91 ) -> Result<Self> {
92 if file_permissions & !0o7777 != 0 || directory_permissions & !0o7777 != 0 {
93 return Err(LinuxCloudFilesError::InvalidConfiguration {
94 reason: "permissions must contain only Unix permission and special-mode bits",
95 });
96 }
97 Ok(Self {
98 uid,
99 gid,
100 file_permissions,
101 directory_permissions,
102 block_size: 4096,
103 cache_ttl,
104 fallback_time: SystemTime::UNIX_EPOCH,
105 })
106 }
107
108 #[must_use]
110 pub const fn uid(&self) -> u32 {
111 self.uid
112 }
113
114 #[must_use]
116 pub const fn gid(&self) -> u32 {
117 self.gid
118 }
119
120 #[must_use]
122 pub const fn file_permissions(&self) -> u16 {
123 self.file_permissions
124 }
125
126 #[must_use]
128 pub const fn directory_permissions(&self) -> u16 {
129 self.directory_permissions
130 }
131
132 #[must_use]
134 pub const fn block_size(&self) -> u32 {
135 self.block_size
136 }
137
138 #[must_use]
140 pub const fn cache_ttl(&self) -> Duration {
141 self.cache_ttl
142 }
143
144 #[must_use]
146 pub const fn fallback_time(&self) -> SystemTime {
147 self.fallback_time
148 }
149}
150
151impl Default for LinuxAttributePolicy {
152 fn default() -> Self {
153 Self {
154 uid: 0,
155 gid: 0,
156 file_permissions: 0o444,
157 directory_permissions: 0o555,
158 block_size: 4096,
159 cache_ttl: Duration::from_secs(1),
160 fallback_time: SystemTime::UNIX_EPOCH,
161 }
162 }
163}
164
165#[derive(Debug, Clone, PartialEq, Eq)]
167pub struct LinuxFileAttributes {
168 inode: LinuxInode,
169 size: u64,
170 blocks: u64,
171 kind: LinuxNodeKind,
172 permissions: u16,
173 links: u32,
174 uid: u32,
175 gid: u32,
176 block_size: u32,
177 time: SystemTime,
178}
179
180impl LinuxFileAttributes {
181 #[must_use]
183 pub const fn inode(&self) -> LinuxInode {
184 self.inode
185 }
186
187 #[must_use]
189 pub const fn size(&self) -> u64 {
190 self.size
191 }
192
193 #[must_use]
195 pub const fn blocks(&self) -> u64 {
196 self.blocks
197 }
198
199 #[must_use]
201 pub const fn kind(&self) -> LinuxNodeKind {
202 self.kind
203 }
204
205 #[must_use]
207 pub const fn permissions(&self) -> u16 {
208 self.permissions
209 }
210
211 #[must_use]
213 pub const fn links(&self) -> u32 {
214 self.links
215 }
216
217 #[must_use]
219 pub const fn uid(&self) -> u32 {
220 self.uid
221 }
222
223 #[must_use]
225 pub const fn gid(&self) -> u32 {
226 self.gid
227 }
228
229 #[must_use]
231 pub const fn block_size(&self) -> u32 {
232 self.block_size
233 }
234
235 #[must_use]
237 pub const fn time(&self) -> SystemTime {
238 self.time
239 }
240}
241
242#[derive(Debug, Clone, PartialEq, Eq)]
244pub struct LinuxNode {
245 key: CloudItemKey,
246 generation: LinuxInodeGeneration,
247 attributes: LinuxFileAttributes,
248}
249
250impl LinuxNode {
251 #[must_use]
253 pub const fn key(&self) -> &CloudItemKey {
254 &self.key
255 }
256
257 #[must_use]
259 pub const fn generation(&self) -> LinuxInodeGeneration {
260 self.generation
261 }
262
263 #[must_use]
265 pub const fn attributes(&self) -> &LinuxFileAttributes {
266 &self.attributes
267 }
268
269 pub(crate) fn with_size(mut self, size: u64) -> Self {
270 self.attributes.size = size;
271 self.attributes.blocks = size.div_ceil(FUSE_BLOCK_SIZE);
272 self
273 }
274}
275
276#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
278pub struct LinuxFileHandle(u64);
279
280impl LinuxFileHandle {
281 pub const fn new(value: u64) -> Result<Self> {
287 if value == 0 {
288 return Err(LinuxCloudFilesError::StaleHandle);
289 }
290 Ok(Self(value))
291 }
292
293 #[must_use]
295 pub const fn get(self) -> u64 {
296 self.0
297 }
298}
299
300#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
302pub struct LinuxDirectoryHandle(u64);
303
304impl LinuxDirectoryHandle {
305 pub const fn new(value: u64) -> Result<Self> {
311 if value == 0 {
312 return Err(LinuxCloudFilesError::StaleHandle);
313 }
314 Ok(Self(value))
315 }
316
317 #[must_use]
319 pub const fn get(self) -> u64 {
320 self.0
321 }
322}
323
324#[derive(Debug, Clone, PartialEq, Eq)]
326pub struct LinuxDirectoryEntry {
327 inode: LinuxInode,
328 generation: LinuxInodeGeneration,
329 name: String,
330 kind: LinuxNodeKind,
331}
332
333impl LinuxDirectoryEntry {
334 #[must_use]
336 pub const fn inode(&self) -> LinuxInode {
337 self.inode
338 }
339
340 #[must_use]
342 pub const fn generation(&self) -> LinuxInodeGeneration {
343 self.generation
344 }
345
346 #[must_use]
348 pub fn name(&self) -> &str {
349 &self.name
350 }
351
352 #[must_use]
354 pub const fn kind(&self) -> LinuxNodeKind {
355 self.kind
356 }
357
358 pub(crate) fn from_node(name: String, node: &LinuxNode) -> Self {
359 Self {
360 inode: node.attributes.inode,
361 generation: node.generation,
362 name,
363 kind: node.attributes.kind,
364 }
365 }
366}
367
368#[derive(Debug, Clone, PartialEq, Eq)]
370pub struct LinuxDirectorySnapshot {
371 directory: LinuxInode,
372 parent: LinuxInode,
373 entries: Arc<[LinuxDirectoryEntry]>,
374}
375
376impl LinuxDirectorySnapshot {
377 #[must_use]
379 pub const fn directory(&self) -> LinuxInode {
380 self.directory
381 }
382
383 #[must_use]
385 pub const fn parent(&self) -> LinuxInode {
386 self.parent
387 }
388
389 #[must_use]
391 pub fn entries(&self) -> &[LinuxDirectoryEntry] {
392 &self.entries
393 }
394
395 pub(crate) fn with_entries(
396 directory: LinuxInode,
397 parent: LinuxInode,
398 entries: Vec<LinuxDirectoryEntry>,
399 ) -> Self {
400 Self {
401 directory,
402 parent,
403 entries: entries.into(),
404 }
405 }
406}
407
408#[derive(Debug, Clone)]
409struct OpenFile {
410 inode: LinuxInode,
411 key: CloudItemKey,
412 revision: ContentRevision,
413 size: u64,
414}
415
416#[derive(Default)]
417struct HandleState {
418 next: u64,
419 files: HashMap<LinuxFileHandle, OpenFile>,
420 directories: HashMap<LinuxDirectoryHandle, LinuxDirectorySnapshot>,
421}
422
423impl HandleState {
424 fn allocate(&mut self) -> Result<u64> {
425 let current = if self.next == 0 { 1 } else { self.next };
426 let Some(next) = current.checked_add(1) else {
427 return Err(LinuxCloudFilesError::HandleExhausted);
428 };
429 self.next = next;
430 Ok(current)
431 }
432}
433
434struct EngineInner<B> {
435 backend: Arc<B>,
436 inodes: Arc<LinuxInodeTable>,
437 attributes: LinuxAttributePolicy,
438 handles: Mutex<HandleState>,
439}
440
441pub struct LinuxReadOnlyEngine<B> {
443 inner: Arc<EngineInner<B>>,
444}
445
446impl<B> Clone for LinuxReadOnlyEngine<B> {
447 fn clone(&self) -> Self {
448 Self {
449 inner: self.inner.clone(),
450 }
451 }
452}
453
454impl<B> LinuxReadOnlyEngine<B>
455where
456 B: CloudFilesBackend + 'static,
457{
458 pub fn new(
460 backend: Arc<B>,
461 inodes: Arc<LinuxInodeTable>,
462 attributes: LinuxAttributePolicy,
463 ) -> Self {
464 Self {
465 inner: Arc::new(EngineInner {
466 backend,
467 inodes,
468 attributes,
469 handles: Mutex::new(HandleState::default()),
470 }),
471 }
472 }
473
474 #[must_use]
476 pub fn inode_table(&self) -> &LinuxInodeTable {
477 &self.inner.inodes
478 }
479
480 #[must_use]
482 pub fn attribute_policy(&self) -> &LinuxAttributePolicy {
483 &self.inner.attributes
484 }
485
486 pub async fn lookup(&self, parent: LinuxInode, name: &str) -> Result<LinuxNode> {
492 validate_linux_name(name)?;
493 let parent_item = self.load_item_for_overlay(parent).await?;
494 if parent_item.kind() != CloudItemKind::Directory {
495 return Err(LinuxCloudFilesError::NotDirectory);
496 }
497 let children = self.load_children(parent_item.key()).await?;
498 let Some(item) = children.into_iter().find(|item| item.name() == name) else {
499 return Err(LinuxCloudFilesError::Backend(
500 aster_forge_cloud_files_core::CloudBackendError::new(
501 aster_forge_cloud_files_core::CloudBackendErrorKind::NotFound,
502 ),
503 ));
504 };
505 self.node_from_item(&item)
506 }
507
508 pub async fn getattr(&self, inode: LinuxInode) -> Result<LinuxNode> {
514 let item = self.load_item_for_overlay(inode).await?;
515 self.node_from_item(&item)
516 }
517
518 pub async fn open_file(&self, inode: LinuxInode) -> Result<LinuxFileHandle> {
524 let item = self.load_item_for_overlay(inode).await?;
525 if item.kind() != CloudItemKind::File {
526 return Err(LinuxCloudFilesError::NotFile);
527 }
528 let Some(content) = item.content() else {
529 return Err(LinuxCloudFilesError::InvalidBackendResponse {
530 reason: "regular file omitted content metadata",
531 });
532 };
533 let mut handles = lock(&self.inner.handles);
534 let handle = LinuxFileHandle(handles.allocate()?);
535 handles.files.insert(
536 handle,
537 OpenFile {
538 inode,
539 key: item.key().clone(),
540 revision: content.revision().clone(),
541 size: content.size(),
542 },
543 );
544 Ok(handle)
545 }
546
547 pub(crate) async fn hydrate_for_write(
548 &self,
549 inode: LinuxInode,
550 session_generation: SessionGeneration,
551 ) -> Result<(crate::LinuxWriteOpenRequest, Bytes)> {
552 let item = self.load_item_for_overlay(inode).await?;
553 self.hydrate_item_for_write(&item, session_generation).await
554 }
555
556 pub(crate) async fn hydrate_item_for_write(
557 &self,
558 item: &CloudItem,
559 session_generation: SessionGeneration,
560 ) -> Result<(crate::LinuxWriteOpenRequest, Bytes)> {
561 if item.kind() != CloudItemKind::File {
562 return Err(LinuxCloudFilesError::NotFile);
563 }
564 let content = item
565 .content()
566 .ok_or(LinuxCloudFilesError::InvalidBackendResponse {
567 reason: "regular file omitted content metadata",
568 })?;
569 let request = ContentReadRequest::whole(
570 item.key().clone(),
571 content.revision().clone(),
572 content.size(),
573 );
574 let response = self.inner.backend.read_content(&request).await?;
575 request.validate_response(&response).map_err(|_| {
576 LinuxCloudFilesError::InvalidBackendResponse {
577 reason: "write hydration violated the requested revision or complete-file extent",
578 }
579 })?;
580 let (_, _, bytes, _) = response.into_parts();
581 Ok((
582 crate::LinuxWriteOpenRequest::new(
583 item.key().clone(),
584 content.revision().clone(),
585 content.size(),
586 session_generation,
587 ),
588 bytes,
589 ))
590 }
591
592 pub async fn read_file(
598 &self,
599 inode: LinuxInode,
600 handle: LinuxFileHandle,
601 offset: u64,
602 size: u32,
603 ) -> Result<Bytes> {
604 let opened = {
605 let handles = lock(&self.inner.handles);
606 let Some(opened) = handles.files.get(&handle) else {
607 return Err(LinuxCloudFilesError::StaleHandle);
608 };
609 if opened.inode != inode {
610 return Err(LinuxCloudFilesError::StaleHandle);
611 }
612 opened.clone()
613 };
614 if size == 0 || offset >= opened.size {
615 return Ok(Bytes::new());
616 }
617 let length = u64::from(size).min(opened.size - offset);
618 let range = ByteRange::new(offset, length).map_err(|_| {
619 LinuxCloudFilesError::InvalidBackendResponse {
620 reason: "validated FUSE range could not be represented by the core model",
621 }
622 })?;
623 let request = ContentReadRequest::range(opened.key, opened.revision, opened.size, range);
624 let response = self.inner.backend.read_content(&request).await?;
625 request.validate_response(&response).map_err(|_| {
626 LinuxCloudFilesError::InvalidBackendResponse {
627 reason: "content response violated the requested revision or range",
628 }
629 })?;
630 Ok(response.into_parts().2)
631 }
632
633 pub fn release_file(&self, handle: LinuxFileHandle) -> Result<()> {
639 if lock(&self.inner.handles).files.remove(&handle).is_none() {
640 return Err(LinuxCloudFilesError::StaleHandle);
641 }
642 Ok(())
643 }
644
645 pub async fn open_directory(&self, inode: LinuxInode) -> Result<LinuxDirectoryHandle> {
651 let item = self.load_item_for_overlay(inode).await?;
652 if item.kind() != CloudItemKind::Directory {
653 return Err(LinuxCloudFilesError::NotDirectory);
654 }
655 let children = self.load_children(item.key()).await?;
656 let entries = children
657 .iter()
658 .map(|child| {
659 let node = self.node_from_item(child)?;
660 Ok(LinuxDirectoryEntry {
661 inode: node.attributes.inode,
662 generation: node.generation,
663 name: child.name().to_owned(),
664 kind: node.attributes.kind,
665 })
666 })
667 .collect::<Result<Vec<_>>>()?;
668 let parent = match item.parent_id() {
669 Some(parent_id) => {
670 let parent_key = CloudItemKey::new(item.key().scope().clone(), parent_id.clone());
671 self.inner
672 .inodes
673 .by_key(&parent_key)
674 .ok_or(LinuxCloudFilesError::MissingInodeRecord)?
675 .inode()
676 }
677 None => inode,
678 };
679 let snapshot = LinuxDirectorySnapshot {
680 directory: inode,
681 parent,
682 entries: entries.into(),
683 };
684 let mut handles = lock(&self.inner.handles);
685 let handle = LinuxDirectoryHandle(handles.allocate()?);
686 handles.directories.insert(handle, snapshot);
687 Ok(handle)
688 }
689
690 pub fn directory_snapshot(
696 &self,
697 inode: LinuxInode,
698 handle: LinuxDirectoryHandle,
699 ) -> Result<LinuxDirectorySnapshot> {
700 let handles = lock(&self.inner.handles);
701 let Some(snapshot) = handles.directories.get(&handle) else {
702 return Err(LinuxCloudFilesError::StaleHandle);
703 };
704 if snapshot.directory != inode {
705 return Err(LinuxCloudFilesError::StaleHandle);
706 }
707 Ok(snapshot.clone())
708 }
709
710 pub fn release_directory(&self, handle: LinuxDirectoryHandle) -> Result<()> {
716 if lock(&self.inner.handles)
717 .directories
718 .remove(&handle)
719 .is_none()
720 {
721 return Err(LinuxCloudFilesError::StaleHandle);
722 }
723 Ok(())
724 }
725
726 pub(crate) async fn load_item_for_overlay(&self, inode: LinuxInode) -> Result<CloudItem> {
727 let record = self
728 .inner
729 .inodes
730 .by_inode(inode)
731 .ok_or(LinuxCloudFilesError::UnknownInode { inode: inode.get() })?;
732 let item = self.inner.backend.get_item(record.key()).await?;
733 if item.key() != record.key() {
734 return Err(LinuxCloudFilesError::InvalidBackendResponse {
735 reason: "get_item returned a different scoped stable identity",
736 });
737 }
738 if item.is_root() != (inode == LINUX_ROOT_INODE) {
739 return Err(LinuxCloudFilesError::InvalidBackendResponse {
740 reason: "get_item root shape did not match the restored inode role",
741 });
742 }
743 Ok(item)
744 }
745
746 pub(crate) async fn load_children_for_overlay(
747 &self,
748 parent: &CloudItemKey,
749 ) -> Result<Vec<CloudItem>> {
750 let mut cursor: Option<PageCursor> = None;
751 let mut seen_cursors = HashSet::new();
752 let mut seen_names = HashSet::new();
753 let mut children = Vec::new();
754 loop {
755 let page = self
756 .inner
757 .backend
758 .list_children(parent, cursor.as_ref())
759 .await?;
760 let (items, next_cursor) = page.into_parts();
761 for item in items {
762 if item.key().scope() != parent.scope()
763 || item.parent_id() != Some(parent.item_id())
764 {
765 return Err(LinuxCloudFilesError::InvalidBackendResponse {
766 reason: "directory child escaped the requested parent scope",
767 });
768 }
769 validate_linux_name(item.name())?;
770 if !seen_names.insert(item.name().to_owned()) {
771 return Err(LinuxCloudFilesError::InvalidBackendResponse {
772 reason: "directory enumeration returned duplicate child names",
773 });
774 }
775 children.push(item);
776 }
777 let Some(next_cursor) = next_cursor else {
778 break;
779 };
780 if !seen_cursors.insert(next_cursor.clone()) {
781 return Err(LinuxCloudFilesError::InvalidBackendResponse {
782 reason: "directory pagination repeated a continuation cursor",
783 });
784 }
785 cursor = Some(next_cursor);
786 }
787 Ok(children)
788 }
789
790 async fn load_children(&self, parent: &CloudItemKey) -> Result<Vec<CloudItem>> {
791 let children = self.load_children_for_overlay(parent).await?;
792 if children
793 .iter()
794 .any(|item| self.inner.inodes.by_key(item.key()).is_none())
795 {
796 return Err(LinuxCloudFilesError::MissingInodeRecord);
797 }
798 Ok(children)
799 }
800
801 fn node_from_item(&self, item: &CloudItem) -> Result<LinuxNode> {
802 if !item.is_root() {
803 validate_linux_name(item.name())?;
804 }
805 let record = self
806 .inner
807 .inodes
808 .by_key(item.key())
809 .ok_or(LinuxCloudFilesError::MissingInodeRecord)?;
810 self.node_from_item_and_record(item, record)
811 }
812
813 pub(crate) fn node_from_item_and_record(
814 &self,
815 item: &CloudItem,
816 record: &LinuxInodeRecord,
817 ) -> Result<LinuxNode> {
818 if !item.is_root() {
819 validate_linux_name(item.name())?;
820 }
821 if item.key() != record.key() {
822 return Err(LinuxCloudFilesError::InvalidBackendResponse {
823 reason: "linux inode record did not match the item stable identity",
824 });
825 }
826 let (kind, size, permissions, links) = match item.kind() {
827 CloudItemKind::File => {
828 let Some(content) = item.content() else {
829 return Err(LinuxCloudFilesError::InvalidBackendResponse {
830 reason: "regular file omitted content metadata",
831 });
832 };
833 (
834 LinuxNodeKind::File,
835 content.size(),
836 self.inner.attributes.file_permissions,
837 1,
838 )
839 }
840 CloudItemKind::Directory => (
841 LinuxNodeKind::Directory,
842 0,
843 self.inner.attributes.directory_permissions,
844 2,
845 ),
846 };
847 Ok(LinuxNode {
848 key: item.key().clone(),
849 generation: record.generation(),
850 attributes: LinuxFileAttributes {
851 inode: record.inode(),
852 size,
853 blocks: size.div_ceil(FUSE_BLOCK_SIZE),
854 kind,
855 permissions,
856 links,
857 uid: self.inner.attributes.uid,
858 gid: self.inner.attributes.gid,
859 block_size: self.inner.attributes.block_size,
860 time: self.inner.attributes.fallback_time,
861 },
862 })
863 }
864}