api/services/
moss.rs

1use std::path::PathBuf;
2use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
3use tokio::net::TcpStream;
4use zip::ZipArchive;
5use std::io::Cursor;
6use std::path::Path;
7
8const MOSS_SERVER: &str = "moss.stanford.edu";
9const MOSS_PORT: u16 = 7690;
10
11/// A service for interacting with the MOSS (Measure of Software Similarity) server.
12pub struct MossService {
13    user_id: String,
14}
15
16impl MossService {
17    /// Creates a new `MossService` with the given user ID.
18    pub fn new(user_id: &str) -> Self {
19        Self {
20            user_id: user_id.to_string(),
21        }
22    }
23
24    /// Runs a MOSS check by uploading base files and submission files to the MOSS server.
25    ///
26    /// # Arguments
27    ///
28    /// * `base_files` - A list of files to be used as the base for comparison (template/starter code).
29    /// * `submission_files` - A list of tuples containing (file_path, optional_username) for submissions.
30    /// * `language` - The programming language of the files.
31    ///
32    /// # Returns
33    ///
34    /// A `Result` containing the MOSS report URL on success, or an error message on failure.
35    pub async fn run(
36        &self,
37        base_files: Vec<PathBuf>,
38        submission_files: Vec<(PathBuf, Option<String>, Option<i64>)>,
39        language: &str,
40    ) -> Result<String, String> {
41        if submission_files.len() < 2 {
42            return Err("MOSS requires at least 2 submission files to compare".to_string());
43        }
44        
45        let mut stream = TcpStream::connect((MOSS_SERVER, MOSS_PORT))
46            .await
47            .map_err(|e| format!("Failed to connect to MOSS server: {}", e))?;
48
49        self.send_command(&mut stream, &format!("moss {}", self.user_id)).await?;
50        self.send_command(&mut stream, "directory 0").await?;
51        self.send_command(&mut stream, "X 0").await?;
52        self.send_command(&mut stream, "maxmatches 10").await?;
53        self.send_command(&mut stream, "show 250").await?;
54        self.send_command(&mut stream, &format!("language {}", language)).await?;
55        
56        let mut response = String::new();
57        {
58            let mut reader = BufReader::new(&mut stream);
59            reader.read_line(&mut response).await.map_err(|e| {
60                format!("Failed to read language response: {}", e)
61            })?;
62        }
63        
64        if response.trim() == "no" {
65            return Err(format!("Language '{}' not supported by MOSS", language));
66        }
67
68        for path in &base_files {
69            self.upload_file(&mut stream, path, 0, language, None, None).await?;
70        }
71
72        let mut file_id = 1u32;
73        for (path, username, submission_id) in &submission_files {
74            if path.extension().and_then(|s| s.to_str()) == Some("zip") {
75                self.send_command(&mut stream, "directory 1").await?;
76                file_id = self.upload_zip(
77                    &mut stream, 
78                    path, 
79                    file_id, 
80                    language, 
81                    username.as_deref(), 
82                    *submission_id
83                ).await?;
84                self.send_command(&mut stream, "directory 0").await?;
85            } else {
86                file_id = self.upload_file(&mut stream, path, file_id, language, username.as_deref(), *submission_id).await?;
87            }
88        }
89
90        self.send_command(&mut stream, "query 0 ").await?;
91
92        let mut response = String::new();
93        {
94            let mut reader = BufReader::new(&mut stream);
95            reader.read_line(&mut response).await.map_err(|e| {
96                format!("Failed to read query response: {}", e)
97            })?;
98        }
99        
100        let report_url = response.trim().to_string();
101
102        self.send_command(&mut stream, "end").await?;
103        
104        if !report_url.starts_with("http") {
105            return Err(format!("Invalid response from MOSS server: '{}'", report_url));
106        }
107
108        Ok(report_url)
109    }
110
111    async fn send_command(&self, stream: &mut TcpStream, command: &str) -> Result<(), String> {
112        let command_with_newline = format!("{}\n", command);
113        stream.write_all(command_with_newline.as_bytes()).await.map_err(|e| {
114            format!("Failed to send command '{}': {}", command, e)
115        })?;
116        Ok(())
117    }
118
119    /// Uploads a single file to the MOSS server with username and submission ID prefix.
120    /// Returns the next file_id.
121    async fn upload_file(
122        &self,
123        stream: &mut TcpStream,
124        path: &Path,
125        file_id: u32,
126        language: &str,
127        username: Option<&str>,
128        submission_id: Option<i64>,
129    ) -> Result<u32, String> {
130        if !path.exists() {
131            return Err(format!("File does not exist: {}", path.display()));
132        }
133
134        let content = tokio::fs::read(path).await.map_err(|e| {
135            format!("Failed to read file {}: {}", path.display(), e)
136        })?;
137
138        let original_filename = path
139            .file_name()
140            .ok_or_else(|| format!("Invalid filename: {}", path.display()))?
141            .to_str()
142            .ok_or_else(|| format!("Non-UTF8 filename: {}", path.display()))?
143            .replace(' ', "_");
144
145        let filename = match (username, submission_id) {
146            (Some(username), Some(submission_id)) => {
147                format!("{}_{}_{}", username, submission_id, original_filename)
148            }
149            (Some(username), None) => {
150                format!("{}_{}", username, original_filename)
151            }
152            (None, Some(submission_id)) => {
153                format!("{}_{}", submission_id, original_filename)
154            }
155            (None, None) => original_filename,
156        };
157
158        let command = format!("file {} {} {} {}", file_id, language, content.len(), filename);
159        self.send_command(stream, &command).await?;
160        
161        stream.write_all(&content).await.map_err(|e| {
162            format!("Failed to upload file content: {}", e)
163        })?;
164        
165        Ok(file_id + 1)
166    }
167
168    /// Uploads a ZIP file as a single directory submission
169    /// Returns the next file_id.
170    async fn upload_zip(
171        &self,
172        stream: &mut TcpStream,
173        zip_path: &Path,
174        starting_file_id: u32,
175        language: &str,
176        username: Option<&str>,
177        submission_id: Option<i64>,
178    ) -> Result<u32, String> {
179        let zip_data = tokio::fs::read(zip_path).await.map_err(|e| {
180            format!("Failed to read ZIP file {}: {}", zip_path.display(), e)
181        })?;
182        
183        let cursor = Cursor::new(zip_data);
184        let mut archive = ZipArchive::new(cursor).map_err(|e| {
185            format!("Failed to open ZIP file {}: {}", zip_path.display(), e)
186        })?;
187        
188        let directory_name = match (username, submission_id) {
189            (Some(u), Some(sid)) => format!("{}_{}", u, sid),
190            (Some(u), None) => format!("{}", u),
191            (None, Some(sid)) => format!("{}", sid),
192            (None, None) => "".to_string(),
193        };
194        
195        let sanitized_dir_name = directory_name
196            .replace('/', "_")
197            .replace('\\', "_")
198            .replace(' ', "_");
199
200        let mut current_file_id = starting_file_id;
201        let mut files_uploaded = 0;
202
203        for i in 0..archive.len() {
204            let mut file = archive.by_index(i).map_err(|e| {
205                format!("Failed to read file {} from ZIP: {}", i, e)
206            })?;
207            
208            if file.is_dir() {
209                continue;
210            }
211
212            let mut contents = Vec::new();
213            std::io::copy(&mut file, &mut contents).map_err(|e| {
214                format!("Failed to read file contents: {}", e)
215            })?;
216
217            let internal_path = file.name()
218                .replace('\\', "/")
219                .trim_start_matches('/')
220                .to_string();
221            
222            if internal_path.is_empty() {
223                continue;
224            }
225
226            let display_name = format!("{}/{}", sanitized_dir_name, internal_path)
227                .replace(' ', "_");
228
229            let command = format!(
230                "file {} {} {} {}",
231                current_file_id,
232                language,
233                contents.len(),
234                display_name
235            );
236
237            self.send_command(stream, &command).await?;
238            stream.write_all(&contents).await.map_err(|e| {
239                format!("Failed to upload file content: {}", e)
240            })?;
241
242            current_file_id += 1;
243            files_uploaded += 1;
244        }
245
246        if files_uploaded == 0 {
247            return Err(format!("ZIP contained no valid files: {}", zip_path.display()));
248        }
249
250        Ok(current_file_id)
251    }
252}