unftp_sbe_iso/
lib.rs

1//! A [libunftp](https://docs.rs/libunftp/latest/libunftp/)
2//! storage back-end that allows FTP access to ".iso" (ISO 9660) files.
3//!
4//! ## Usage
5//!
6//! Add the `libunftp` and `tokio` crates to your project's dependencies in Cargo.toml:
7//!
8//! ```toml
9//! [dependencies]
10//! libunftp = "0.20.3"
11//! unftp-sbe-iso = "0.1"
12//! tokio = { version = "1", features = ["full"] }
13//! ```
14//!
15//! Add the following to src/main.rs:
16//!
17//! ```no_run
18//! use libunftp::ServerBuilder;
19//! use unftp_sbe_iso::Storage;
20//
21//! #[tokio::main(flavor = "current_thread")]
22//! async fn main() {
23//!     let addr = "127.0.0.1:2121";
24//!
25//!     let server = ServerBuilder::new(Box::new(move || Storage::new("/path/to/your/image.iso")))
26//!         .greeting("Welcome to my ISO over FTP")
27//!         .passive_ports(50000..=65535)
28//!         .build()
29//!         .unwrap();
30//!
31//!     println!("Starting FTP server on {}", addr);
32//!     server.listen(addr).await.unwrap();
33//! }
34//! ```
35//!
36//! You can now run your server with cargo run and connect to localhost:2121 with your favourite FTP client e.g.:
37//!
38//! ```sh
39//! lftp localhost -p 2121
40//! ```
41
42use async_trait::async_trait;
43use cdfs::{DirectoryEntry, ExtraAttributes, ISO9660, ISODirectory, ISOFileReader};
44use libunftp::{
45    auth::UserDetail,
46    storage::{Error, ErrorKind},
47    storage::{Fileinfo, Metadata, Result, StorageBackend},
48};
49use std::{
50    fmt::Debug,
51    fs::File,
52    io::{Cursor, Read, Seek, SeekFrom},
53    path::{Path, PathBuf},
54    time::SystemTime,
55};
56use tokio::io::AsyncRead;
57
58/// A virtual file system that tells libunftp how to access ".iso" (ISO 9660) files.
59#[derive(Debug, Clone)]
60pub struct Storage {
61    iso_path: PathBuf,
62}
63
64impl Storage {
65    /// Creates the storage back-end, pointing it to the ".iso" file
66    /// given in the `iso_path` parameter.
67    pub fn new<P: AsRef<Path>>(iso_path: P) -> Self {
68        Self {
69            iso_path: iso_path.as_ref().to_path_buf(),
70        }
71    }
72
73    fn open_iso(&self) -> std::io::Result<ISO9660<std::fs::File>> {
74        let file = std::fs::File::open(&self.iso_path)?;
75        Ok(ISO9660::new(file).unwrap())
76    }
77
78    fn find<P: AsRef<Path> + Send + Debug>(&self, path: P) -> Result<DirectoryEntry<File>> {
79        let iso: ISO9660<File> = self.open_iso()?;
80        let mut current_dir: ISODirectory<File> = iso.root().clone();
81
82        let mut components = path.as_ref().components().peekable();
83
84        while let Some(comp) = components.next() {
85            use std::path::Component;
86
87            let name = match comp {
88                Component::RootDir => continue,
89                Component::Normal(name) => name.to_str().unwrap().to_uppercase(),
90                _ => {
91                    return Err(Error::new(
92                        ErrorKind::PermanentFileNotAvailable,
93                        "Unsupported path component",
94                    ));
95                }
96            };
97
98            // Find the next entry in the current directory
99            let next_entry: DirectoryEntry<File> = current_dir
100                .contents()
101                .filter_map(|e| e.ok())
102                .find(|e| e.identifier().eq_ignore_ascii_case(&name))
103                .ok_or_else(|| {
104                    Error::new(
105                        ErrorKind::TransientFileNotAvailable,
106                        format!("Path component '{}' not found", name),
107                    )
108                })?;
109
110            if components.peek().is_none() {
111                // This is the last component — return the entry
112                return Ok(next_entry);
113            }
114
115            // Not the last component — must be a directory
116            match next_entry {
117                DirectoryEntry::Directory(dir) => {
118                    current_dir = dir; // move the directory, no borrow
119                }
120                _ => {
121                    return Err(Error::new(
122                        ErrorKind::PermanentFileNotAvailable,
123                        "Intermediate path component is not a directory",
124                    ));
125                }
126            }
127        }
128
129        // If we get here, it means the path was `/` or empty — return root dir entry
130        Ok(DirectoryEntry::Directory(current_dir))
131    }
132}
133
134#[async_trait]
135impl<User: UserDetail> StorageBackend<User> for Storage {
136    type Metadata = IsoMeta;
137
138    async fn metadata<P: AsRef<Path> + Send + Debug>(
139        &self,
140        _user: &User,
141        path: P,
142    ) -> Result<Self::Metadata> {
143        let entry = self.find(path)?;
144        let size = match &entry {
145            DirectoryEntry::Directory(d) => d.header().length as u64,
146            DirectoryEntry::File(f) => f.size() as u64,
147            DirectoryEntry::Symlink(l) => l.header().length as u64,
148        };
149        Ok(IsoMeta {
150            len: size,
151            dir: matches!(entry, DirectoryEntry::Directory(_)),
152            sym: matches!(entry, DirectoryEntry::Symlink(_)),
153            group: entry.group().unwrap_or(0),
154            owner: entry.owner().unwrap_or(0),
155            modified: entry.modify_time().into(),
156        })
157    }
158
159    async fn list<P: AsRef<Path> + Send + Debug>(
160        &self,
161        _user: &User,
162        path: P,
163    ) -> Result<Vec<Fileinfo<PathBuf, Self::Metadata>>>
164    where
165        <Self as StorageBackend<User>>::Metadata: Metadata,
166    {
167        let mut entries = Vec::new();
168        let e = self.find(path)?;
169        let d = match e {
170            DirectoryEntry::Directory(d) => d,
171            DirectoryEntry::File(_) => return Err(Error::from(ErrorKind::FileNameNotAllowedError)),
172            DirectoryEntry::Symlink(_) => {
173                return Err(Error::from(ErrorKind::FileNameNotAllowedError));
174            }
175        };
176        for entry in d.contents() {
177            let e = entry.unwrap();
178            let size = match &e {
179                DirectoryEntry::Directory(d) => d.header().length as u64,
180                DirectoryEntry::File(f) => f.size() as u64,
181                DirectoryEntry::Symlink(l) => l.header().length as u64,
182            };
183            entries.push(Fileinfo {
184                path: e.identifier().into(),
185                metadata: IsoMeta {
186                    len: size,
187                    dir: matches!(e, DirectoryEntry::Directory(_)),
188                    sym: matches!(e, DirectoryEntry::Symlink(_)),
189                    group: e.group().unwrap_or(0),
190                    owner: e.owner().unwrap_or(0),
191                    modified: e.modify_time().into(),
192                },
193            });
194        }
195        Ok(entries)
196    }
197
198    async fn get<P: AsRef<Path> + Send + Debug>(
199        &self,
200        _user: &User,
201        path: P,
202        start_pos: u64,
203    ) -> Result<Box<dyn AsyncRead + Send + Sync + Unpin>> {
204        let entry: DirectoryEntry<File> = self.find(path)?;
205        match entry {
206            DirectoryEntry::File(file_entry) => {
207                let mut reader: ISOFileReader<File> = file_entry.read();
208                // Seek to the requested start position
209                if start_pos > 0 {
210                    reader.seek(SeekFrom::Start(start_pos)).map_err(|e| {
211                        Error::new(
212                            ErrorKind::PermanentFileNotAvailable,
213                            format!("seek error: {e}"),
214                        )
215                    })?;
216                }
217
218                // Read entire contents into a Vec<u8>
219                let mut buf = Vec::new();
220                reader.read_to_end(&mut buf).map_err(|e| {
221                    Error::new(
222                        ErrorKind::PermanentFileNotAvailable,
223                        format!("read error: {e}"),
224                    )
225                })?;
226
227                // Return a cursor over the buffer to provide async access
228                let cursor = Cursor::new(buf);
229                Ok(Box::new(cursor))
230            }
231
232            DirectoryEntry::Directory(_) => Err(ErrorKind::PermanentFileNotAvailable.into()),
233            DirectoryEntry::Symlink(_) => Err(ErrorKind::PermanentFileNotAvailable.into()),
234        }
235    }
236
237    async fn put<P: AsRef<Path> + Send + Debug, R: AsyncRead + Send + Sync + Unpin + 'static>(
238        &self,
239        _user: &User,
240        _input: R,
241        _path: P,
242        _start_pos: u64,
243    ) -> Result<u64> {
244        Err(Error::from(ErrorKind::PermissionDenied))
245    }
246
247    async fn del<P: AsRef<Path> + Send + Debug>(&self, _user: &User, _path: P) -> Result<()> {
248        Err(Error::from(ErrorKind::PermissionDenied))
249    }
250
251    async fn mkd<P: AsRef<Path> + Send + Debug>(&self, _user: &User, _path: P) -> Result<()> {
252        Err(Error::from(ErrorKind::PermissionDenied))
253    }
254
255    async fn rename<P: AsRef<Path> + Send + Debug>(
256        &self,
257        _user: &User,
258        _from: P,
259        _to: P,
260    ) -> Result<()> {
261        Err(Error::from(ErrorKind::PermissionDenied))
262    }
263
264    async fn rmd<P: AsRef<Path> + Send + Debug>(&self, _user: &User, _path: P) -> Result<()> {
265        Err(Error::from(ErrorKind::PermissionDenied))
266    }
267
268    async fn cwd<P: AsRef<Path> + Send + Debug>(&self, _user: &User, path: P) -> Result<()> {
269        self.find(path).map(|_d| ())
270    }
271}
272
273/// Implements libunftp's Metadata trait
274#[derive(Debug)]
275pub struct IsoMeta {
276    /// The file size in bytes
277    pub len: u64,
278    /// Is it a directory?
279    pub dir: bool,
280    /// Is it a symbolic link?
281    pub sym: bool,
282    /// The Unix group ID if available, otherwise 0
283    pub group: u32,
284    /// The Unix UID if available, otherwise 0
285    pub owner: u32,
286    /// The last modified time of the file
287    pub modified: SystemTime,
288}
289
290impl Metadata for IsoMeta {
291    fn len(&self) -> u64 {
292        self.len
293    }
294
295    fn is_dir(&self) -> bool {
296        self.dir
297    }
298
299    fn is_file(&self) -> bool {
300        !self.dir
301    }
302
303    fn is_symlink(&self) -> bool {
304        false
305    }
306
307    fn modified(&self) -> Result<SystemTime> {
308        Ok(self.modified)
309    }
310
311    fn gid(&self) -> u32 {
312        self.group
313    }
314
315    fn uid(&self) -> u32 {
316        self.owner
317    }
318}