diff --git a/ainigma-backend/Cargo.toml b/ainigma-backend/Cargo.toml index d46df83..2842d6a 100644 --- a/ainigma-backend/Cargo.toml +++ b/ainigma-backend/Cargo.toml @@ -7,15 +7,24 @@ edition = "2024" ainigma = { path = "../ainigma" } # and any other dependencies you need: -hyper = "1.6.0" axum = "0.8.4" -tokio = { version = "1", features = ["full"] } -uuid = { version = "1.17.0", features = ["v7", "serde"] } +tokio = { version = "1", features = ["macros", "rt-multi-thread", "fs"] } +uuid = { version = "1.17.0", features = ["v4", "v7", "serde"] } serde = { version = "1.0.211", features = ["derive"] } serde_json = "1" tower = "0.5.2" -sqlx = { version = "0.8.6", features = ["postgres", "runtime-tokio-rustls"] } +sqlx = { version = "0.8.6", features = ["postgres", "uuid", "runtime-tokio-rustls"] } +thiserror = "2" +lazy_static = "1.5.0" +regex = "1.11.1" +tracing = "0.1.41" +tracing-subscriber = { version = "0.3.19", features = ["env-filter", "fmt"]} +chrono = "0.4.41" +anyhow = "1.0.98" +mime_guess = "2.0.5" +tokio-util = "0.7.16" [dev-dependencies] reqwest = { version = "0.12.19", features = ["json"] } -tokio = { version = "1", features = ["full"] } +tokio = { version = "1", features = ["macros", "rt-multi-thread", "fs"] } +httpc-test = "0.1.1" diff --git a/ainigma-backend/README.md b/ainigma-backend/README.md index 6aef51f..1773c66 100644 --- a/ainigma-backend/README.md +++ b/ainigma-backend/README.md @@ -14,14 +14,12 @@ Other things they need to take for backend to work ## Software Sqlx - Database -Smithy - Generating code for backend ## Serverside structure ``` /srv/ainigma/data/ /courses/ - Index file (index for quick course lookup and listing) // (or name) config.toml (defined name for pathing) // (name) @@ -39,10 +37,12 @@ Smithy - Generating code for backend courses (1) ── (many) categories (1) ── (many) tasks users (1) ── (many) user_task_progress (many) ── (1) tasks -## workflow +## Workflow +``` [Client] | +|-- Request for structures course, category (static response figured at server start) --> |-- Request (uuid, task_id, course_id) --> | [Server] @@ -53,8 +53,9 @@ users (1) ── (many) user_task_progress (many) ── (1) tasks | |-- Generate flags | |-- Build task using course config | |-- Save built task +| |-- Add Correct flag / answer to database |-- Return task data --> -|-- Add Correct flag / answer to database +| [Client receives task and starts solving] [Client] | @@ -67,15 +68,16 @@ users (1) ── (many) user_task_progress (many) ── (1) tasks |-- No: send feedback | [Client] receives feedback - +``` ## Questions -- Category no identifier and task has String + - Course secret storage? -- Changes only when server down? (configuration checked at start and expected to be correct during runtime) - or updates? (updates to config during server runtime, checked in runtime with functionality locked during update process ) +- Database and authentication? + ## Feedback - No support for v7 uuid in postgre only v4 -- New build function that takes a uuid, and just takes module and task_id +- config catogories.tasks.build path made obsolete in backend - backend always knows what task to process + diff --git a/ainigma-backend/backend.rs b/ainigma-backend/backend.rs deleted file mode 100644 index 2ea1302..0000000 --- a/ainigma-backend/backend.rs +++ /dev/null @@ -1,107 +0,0 @@ -use ainigma::flag_generator::{Algorithm, Flag}; -use axum::{Json, Router, routing::post}; -use serde::{Deserialize, Serialize}; -use uuid::Uuid; - -#[derive(Deserialize)] -struct GenerateFlagInput { - identifier: String, - algorithm: Algorithm, - user_id: Uuid, - course_secret: String, - task_id: String, -} - -#[derive(Serialize, Deserialize)] -struct GenerateFlagOutput { - flag: String, -} - -async fn generate_flag_handler(Json(payload): Json) -> Json { - let uuid = payload.user_id; - - let flag = Flag::new_user_flag( - payload.identifier, // identifier - &payload.algorithm, // algorithm - &payload.course_secret, // secret - &payload.task_id, // taskid - &uuid, // uuid - ) - .flag_string(); - - Json(GenerateFlagOutput { flag }) -} - -#[tokio::main] -async fn main() { - let app = Router::new().route("/generate-task", post(generate_flag_handler)); - - println!("Listening on http://localhost:3000"); - let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await.unwrap(); - axum::serve(listener, app).await.unwrap(); -} -fn app() -> Router { - Router::new().route("/generate-task", post(generate_flag_handler)) -} - -#[cfg(test)] -mod tests { - use super::*; - use axum::Json; - use std::net::SocketAddr; - use tokio::task; - use uuid::Uuid; - - #[tokio::test] - async fn test_generate_task_handler() { - let user_id = Uuid::now_v7(); - let input = GenerateFlagInput { - identifier: "id1".to_string(), - user_id: user_id.clone(), - task_id: "task1".to_string(), - algorithm: Algorithm::HMAC_SHA3_256, - course_secret: "secret".to_string(), - }; - - let response = generate_flag_handler(Json(input)).await; - let output = response.0; - - // Assert output is non-empty and contains the task id - assert!(output.flag.starts_with("id1:")); - println!("Generated flag: {}", output.flag); - } - - #[tokio::test] - async fn test_generate_task_integration() { - let app = app(); - - // Spawn the server on a random port - let addr = SocketAddr::from(([127, 0, 0, 1], 3001)); - task::spawn(async move { - let listener = tokio::net::TcpListener::bind(addr).await.unwrap(); - axum::serve(listener, app).await.unwrap(); - }); - - // Give the server time to start - tokio::time::sleep(tokio::time::Duration::from_millis(100)).await; - - let client = reqwest::Client::new(); - let uuid = Uuid::now_v7(); - let res = client - .post("http://localhost:3001/generate-task") - .json(&serde_json::json!({ - "identifier": "id2", - "algorithm": Algorithm::HMAC_SHA3_256, - "user_id": uuid, - "course_secret": "course42", - "task_id": "taskA" - })) - .send() - .await - .unwrap(); - - let body: GenerateFlagOutput = res.json().await.unwrap(); - assert!(body.flag.starts_with("id2:")); - println!("Flag: {}", body.flag); - } -} diff --git a/ainigma-backend/migrations/0001_ainigma.sql b/ainigma-backend/migrations/0001_ainigma.sql index 54c6271..2446813 100644 --- a/ainigma-backend/migrations/0001_ainigma.sql +++ b/ainigma-backend/migrations/0001_ainigma.sql @@ -2,39 +2,61 @@ CREATE EXTENSION IF NOT EXISTS "uuid-ossp"; CREATE TABLE users ( id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), - email VARCHAR UNIQUE NOT NULL, created_at TIMESTAMPTZ DEFAULT now() ); CREATE TABLE courses ( id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), - title VARCHAR NOT NULL, + name VARCHAR NOT NULL, description TEXT, created_at TIMESTAMPTZ DEFAULT now() ); CREATE TABLE categories ( - id SERIAL PRIMARY KEY, course_id UUID NOT NULL REFERENCES courses(id) ON DELETE CASCADE, name VARCHAR NOT NULL, - number INTEGER + number INTEGER NOT NULL, + PRIMARY KEY (course_id, name) ); CREATE TABLE tasks ( - id SERIAL PRIMARY KEY, - category_id INTEGER NOT NULL REFERENCES categories(id) ON DELETE CASCADE, - title VARCHAR NOT NULL, + id VARCHAR NOT NULL, + course_id UUID NOT NULL, + category_name VARCHAR NOT NULL, + name VARCHAR NOT NULL, description TEXT, points INTEGER DEFAULT 1, - created_at TIMESTAMPTZ DEFAULT now() + created_at TIMESTAMPTZ DEFAULT now(), + PRIMARY KEY (course_id, category_name, id), + FOREIGN KEY (course_id, category_name) REFERENCES categories(course_id, name) ON DELETE CASCADE +); + +CREATE TABLE task_stages ( + id VARCHAR NOT NULL, -- stage ID (e.g., "task001A") + course_id UUID NOT NULL, + category_name VARCHAR NOT NULL, + task_id VARCHAR NOT NULL, -- parent task ID + name VARCHAR NOT NULL, + description TEXT, + weight INTEGER DEFAULT 1, + flag JSONB, -- metadata like { kind: "user_derived" } + created_at TIMESTAMPTZ DEFAULT now(), + PRIMARY KEY (course_id, category_name, task_id, id), + FOREIGN KEY (course_id, category_name, task_id) REFERENCES tasks(course_id, category_name, id) ON DELETE CASCADE ); -CREATE TABLE user_task_progress ( - id SERIAL PRIMARY KEY, +CREATE TABLE user_stage_progress ( user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, - task_id INTEGER NOT NULL REFERENCES tasks(id) ON DELETE CASCADE, + course_id UUID NOT NULL, + category_name VARCHAR NOT NULL, + task_id VARCHAR NOT NULL, + stage_id VARCHAR NOT NULL, -- stage ID completed_at TIMESTAMPTZ, + completed BOOLEAN NOT NULL DEFAULT FALSE, score INTEGER, - UNIQUE (user_id, task_id) + PRIMARY KEY (user_id, course_id, category_name, task_id, stage_id), + FOREIGN KEY (course_id, category_name, task_id, stage_id) + REFERENCES task_stages(course_id, category_name, task_id, id) + ON DELETE CASCADE ); \ No newline at end of file diff --git a/ainigma-backend/model.smithy b/ainigma-backend/model.smithy index ca7704d..7c86673 100644 --- a/ainigma-backend/model.smithy +++ b/ainigma-backend/model.smithy @@ -53,9 +53,18 @@ structure UserLoginOutput { } operation ListCourses{ + output: ListCoursesOutput, +} +structure ListCoursesOutput { + courses: Vec +} +structure Course{ + name: String + id: Uuid } + operation GetCourseConfig{ input: CourseConfigInput, output: CourseConfigOutput diff --git a/ainigma-backend/src/backend.rs b/ainigma-backend/src/backend.rs index 2d750b8..a1b4867 100644 --- a/ainigma-backend/src/backend.rs +++ b/ainigma-backend/src/backend.rs @@ -1,81 +1,580 @@ -use ainigma::config::{ModuleConfiguration, Task, read_check_toml, read_toml}; -use ainigma::errors::ConfigError; -use std::fs; -use std::path::Path; +use crate::errors::filesystem::FileSystemError; +use ainigma::build_process::build_task; +use ainigma::config::{ModuleConfiguration, read_toml, server_read_check_toml}; +use axum::Extension; +use axum::body::Body; +use axum::extract::Path as AxumPath; +use axum::{Json, http::StatusCode, response::IntoResponse, response::Response}; +use regex::Regex; +use serde::Serialize; +use serde_json::json; +use std::collections::HashMap; +use std::env; +use std::path::PathBuf; +use std::sync::Arc; +use std::time::{SystemTime, UNIX_EPOCH}; +use tokio::fs::{self, File}; +use tokio::task::JoinSet; +use tokio_util::io::ReaderStream; use uuid::Uuid; -const DATA_PATH: &str = "/srv/ainigma/data"; +lazy_static::lazy_static! { + static ref SAFE_ID_PATTERN: Regex = Regex::new(r"^[a-zA-Z0-9_-]{1,64}$").unwrap(); +} + const COURSES_DIR: &str = "courses"; -pub async fn get_task() -> Result { - // This function is a placeholder for fetching a task information to display. - Ok(true) +fn get_data_path() -> PathBuf { + // Try reading from an environment variable first + if let Ok(path) = env::var("AINIGMA_DATA_PATH") { + PathBuf::from(path) + } else { + // fallback to the default production path + PathBuf::from("/srv/ainigma/data") + } +} +#[derive(Serialize)] +pub struct FileMetadata { + pub name: String, + pub size: u64, + pub last_modified: String, +} + +#[derive(Serialize)] +pub struct TaskMetadataResponse { + pub instructions: String, + pub files: Vec, +} + +#[derive(Clone, Serialize)] +pub struct CourseResponse { + pub id: String, + pub name: String, + pub description: String, +} + +#[derive(Clone, Serialize)] +pub struct CategoryResponse { + pub name: String, + pub number: u8, +} + +#[derive(Clone, Serialize)] +pub struct TaskResponse { + pub id: String, + pub name: String, + pub description: String, +} +#[derive(Clone, Serialize)] +pub struct AnswerPayload { + pub answer: String, +} + +pub struct CheckAnswerResponse { + pub correct: bool, +} + +#[derive(Clone, Serialize)] +pub struct CourseCache { + pub id: Uuid, + pub name: String, + pub description: String, + pub categories: Vec, +} + +#[derive(Clone, Serialize)] +pub struct CategoryCache { + pub name: String, + pub number: u8, + pub tasks: Vec, +} + +#[derive(Clone, Serialize)] +pub struct TaskCache { + pub id: String, + pub name: String, + pub description: String, +} + +#[derive(Clone)] +pub struct Cache { + pub courses: Arc>>, + pub categories: HashMap>>>, + pub tasks: HashMap<(String, String), Arc>>>, +} + +pub async fn list_courses_handler( + Extension(cache): Extension, +) -> Result>, (StatusCode, String)> { + Ok((*(cache.courses)).clone()) +} + +pub async fn categories_handler( + AxumPath(course_id): AxumPath, + Extension(cache): Extension, +) -> Result>, (StatusCode, String)> { + if !SAFE_ID_PATTERN.is_match(&course_id) { + return Err((StatusCode::BAD_REQUEST, "Invalid course ID".to_string())); + } + + match cache.categories.get(&course_id) { + Some(categories_json) => Ok((**categories_json).clone()), + None => Err((StatusCode::NOT_FOUND, "Categories not found".to_string())), + } +} + +pub async fn tasks_handler( + AxumPath((course_id, category_name)): AxumPath<(String, String)>, + Extension(cache): Extension, +) -> Result>, (StatusCode, String)> { + if !SAFE_ID_PATTERN.is_match(&course_id) || !SAFE_ID_PATTERN.is_match(&category_name) { + return Err(( + StatusCode::BAD_REQUEST, + "Invalid course or category ID".to_string(), + )); + } + + match cache.tasks.get(&(course_id, category_name)) { + Some(tasks_json) => Ok((**tasks_json).clone()), + None => Err((StatusCode::NOT_FOUND, "Tasks not found".to_string())), + } +} +pub async fn get_task_metadata( + AxumPath((course_id, category_name, task_id, uuid)): AxumPath<(String, String, String, String)>, +) -> Result, (StatusCode, String)> { + if !SAFE_ID_PATTERN.is_match(&course_id) + || !SAFE_ID_PATTERN.is_match(&category_name) + || !SAFE_ID_PATTERN.is_match(&task_id) + { + return Err(( + StatusCode::BAD_REQUEST, + "Invalid course or category ID".to_string(), + )); + } + // TODO!: add authentication check here + + let task_root = get_data_path() + .join(COURSES_DIR) + .join(&course_id) + .join(&category_name) + .join(&task_id); + + let output_path = task_root.join("output").join(&uuid); + // Check task + let output_exists = match fs::metadata(&output_path).await { + Ok(meta) => meta.is_dir(), + Err(_) => false, + }; + + // if not build it + if !output_exists { + let course_toml_path = get_data_path() + .join(COURSES_DIR) + .join(&course_id) + .join("config.toml"); + let toml = read_toml(course_toml_path) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + let uuid = match Uuid::parse_str(&uuid) { + Ok(uuid) => uuid, + Err(_) => return Err((StatusCode::BAD_REQUEST, "Invalid UUID".to_string())), + }; + let build_result = build_task(&toml, &task_root, &task_id, uuid).await; + if let Err(err) = build_result { + return Err(( + StatusCode::INTERNAL_SERVER_ERROR, + format!("Failed to build task: {err}"), + )); + } + } + + let student_instructions_path = output_path.join("instructions.md"); + let instructions = if let Ok(content) = fs::read_to_string(&student_instructions_path).await { + content + } else { + //TODO!: Add to checking the instructions exists in config check + let universal_instructions_path = task_root.join("instructions.md"); + match fs::read_to_string(&universal_instructions_path).await { + Ok(content) => content, + Err(_) => String::from("No instructions available."), + } + }; + + let mut files = Vec::new(); + let mut entries = match tokio::fs::read_dir(&output_path).await { + Ok(e) => e, + Err(_) => return Err((StatusCode::NOT_FOUND, "Output folder not found".into())), + }; + + while let Ok(Some(entry)) = entries.next_entry().await { + let file_name = entry.file_name().to_string_lossy().into_owned(); + if file_name == "instructions.md" || file_name == "build-manifest.json" { + continue; + } + let meta = entry.metadata().await.map_err(|_| { + ( + StatusCode::INTERNAL_SERVER_ERROR, + "Could not read file metadata".into(), + ) + })?; + + let modified = match entry.metadata().await { + Ok(meta) => meta.modified().unwrap_or_else(|_| SystemTime::now()), + Err(_) => SystemTime::now(), + }; + + let last_modified = match modified.duration_since(UNIX_EPOCH) { + Ok(duration) => { + let datetime = chrono::DateTime::::from(UNIX_EPOCH + duration); + datetime.to_rfc3339() + } + Err(_) => chrono::Utc::now().to_rfc3339(), // fallback in case of clock issues + }; + + files.push(FileMetadata { + name: entry.file_name().to_string_lossy().to_string(), + size: meta.len(), + last_modified, + }); + } + Ok(Json(TaskMetadataResponse { + instructions, + files, + })) +} + +pub async fn download_file_handler( + AxumPath((course_id, category_name, task_id, user_id, file_name)): AxumPath<( + String, + String, + String, + String, + String, + )>, +) -> Result { + if !SAFE_ID_PATTERN.is_match(&course_id) + || !SAFE_ID_PATTERN.is_match(&category_name) + || !SAFE_ID_PATTERN.is_match(&task_id) + || !SAFE_ID_PATTERN.is_match(&user_id) + { + return Err((StatusCode::BAD_REQUEST, "Invalid path".to_string())); + } + + let task_root = get_data_path() + .join(COURSES_DIR) + .join(&course_id) + .join(&category_name) + .join(&task_id); + + let output_path = task_root.join("output").join(&user_id).join(&file_name); + if !output_path.exists() { + return Err((StatusCode::NOT_FOUND, "File not found".to_string())); + } + + let file = File::open(&output_path).await.map_err(|_| { + ( + StatusCode::INTERNAL_SERVER_ERROR, + "File not found".to_string(), + ) + })?; + + let mime_type = mime_guess::from_path(&output_path) + .first_or_octet_stream() + .to_string(); + + let stream = ReaderStream::new(file); + + let response = Response::builder() + .status(StatusCode::OK) + .header( + "Content-Disposition", + format!("attachment; filename=\"{file_name}\""), + ) + .header("Content-Type", mime_type) + .body(Body::from_stream(stream)) + .map_err(|_| { + ( + StatusCode::INTERNAL_SERVER_ERROR, + "Failed to create response".to_string(), + ) + })?; + + Ok(response) +} + +pub async fn check_answer_handler( + AxumPath((course_id, category_name, task_id, user_id)): AxumPath<( + String, + String, + String, + String, + )>, + Json(payload): Json, +) -> Result, (StatusCode, String)> { + // Implement the database checking logic later + if !SAFE_ID_PATTERN.is_match(&course_id) + || !SAFE_ID_PATTERN.is_match(&category_name) + || !SAFE_ID_PATTERN.is_match(&task_id) + || !SAFE_ID_PATTERN.is_match(&user_id) + { + return Err(( + StatusCode::BAD_REQUEST, + "Invalid course or category ID".to_string(), + )); + } + + let path = get_data_path() + .join(COURSES_DIR) + .join(&course_id) + .join(&category_name) + .join(&task_id) + .join("output") + .join(&user_id) + .join("CorrectAnswer.txt"); + + let correct_answer = fs::read_to_string(&path).await.map_err(|_| { + ( + StatusCode::NOT_FOUND, + "Correct Answer for task not found".to_string(), + ) + })?; + + let correct = payload.answer.trim() == correct_answer.trim(); + + Ok(Json(CheckAnswerResponse { correct })) +} +pub struct DataStructureResult { + pub status: DataStructureStatus, + pub cache: Cache, +} + +pub enum DataStructureStatus { + EmptyCoursesFolder, + CoursesLoaded, } +/// Initializes the directory structure for all available courses on the server. +/// +/// # Returns +/// `Ok(DataStructureResult)` on successful structure generation or validation. +/// containing the status of the data structure and a cache of course metadata. +/// `Err(FileSystemError)` if any IO, config, or task-related errors occur. +/// +/// # Errors +/// Returns a `FileSystemError` if: +/// - The base directory or subdirectories cannot be created +/// - A `config.toml` file is missing or invalid +/// - Reading directory entries fails +/// - One of the spawned async tasks encounters an error +pub async fn generate_data_structure() -> Result { + // This function is a placeholder for generating the initial data structure. + let path = get_data_path().join(COURSES_DIR); + fs::create_dir_all(&path).await.map_err(|e| { + FileSystemError::DataFolderError(format!( + "Failed to create or access data path: {} because of {}", + path.to_string_lossy(), + e + )) + })?; + + let mut directories = fs::read_dir(&path).await.map_err(|e| { + FileSystemError::CourseFolderError(format!("Failed to read courses directory: {e}")) + })?; + + let mut join_set = JoinSet::new(); + let mut found_course = false; -pub async fn check_all_config() -> Result { - let courses_path = Path::new(DATA_PATH).join(COURSES_DIR); - let entries = - fs::read_dir(&courses_path).map_err(|e| ConfigError::FileReadError(e.to_string()))?; - for entry_result in entries { - let entry = entry_result.map_err(|e| ConfigError::FileReadError(e.to_string()))?; + while let Some(entry) = directories.next_entry().await.map_err(|e| { + FileSystemError::CourseFolderError(format!("Failed to read courses directory: {e}")) + })? { let path = entry.path(); if path.is_dir() { + found_course = true; let config_path = path.join("config.toml"); - let os_str_path = config_path.as_os_str(); - read_check_toml(os_str_path)?; + if config_path.exists() { + join_set.spawn(async move { + let config = server_read_check_toml(config_path.as_os_str()) + .await + .map_err(|e| FileSystemError::ConfigError(e.to_string()))?; + generate_course_structure(config.clone()) + .await + .map_err(|e| FileSystemError::ConfigError(e.to_string()))?; + build_course_cache(config) + .await + .map_err(|e| FileSystemError::CacheError(e.to_string())) + }); + } else { + return Err(FileSystemError::ConfigError(format!( + "Config file not found in course directory: {}", + path.to_string_lossy() + ))); + } } } - Ok(true) + let mut course_caches: Vec = Vec::new(); + while let Some(res) = join_set.join_next().await { + let course_cache = res.map_err(|e| FileSystemError::JoinError(e.to_string()))??; + course_caches.push(course_cache); + } + let cache = precompute_json_cache(course_caches.clone()) + .await + .map_err(|e| FileSystemError::CacheError(e.to_string()))?; + let status = if found_course { + DataStructureStatus::CoursesLoaded + } else { + DataStructureStatus::EmptyCoursesFolder + }; + Ok(DataStructureResult { status, cache }) } +/// Generates the internal folder structure for a single course based on its configuration. +/// +/// # Arguments +/// * `config` - A parsed `ModuleConfiguration` object representing the course's structure. +/// +/// # Returns +/// `Ok(true)` on success, or `Err(FileSystemError)` if any directory creation fails. +/// +pub async fn generate_course_structure( + config: ModuleConfiguration, +) -> Result { + let path = get_data_path() + .join(COURSES_DIR) + .join(config.identifier.to_string()); + fs::create_dir_all(&path).await.map_err(|e| { + FileSystemError::CourseFolderError(format!( + "Failed to create course directory: {} because of {}", + path.to_string_lossy(), + e + )) + })?; -pub async fn find_course_config(course_id: Uuid) -> Result { - // TODO: CHECK RACE CONDITION (Locking the directory) - let courses_path = Path::new(DATA_PATH).join(COURSES_DIR); - let course_string = course_id.to_string(); + for category in &config.categories { + let category_dir = path.join(&category.name); + fs::create_dir_all(&category_dir).await.map_err(|e| { + FileSystemError::CategoryFolderError(format!( + "Failed to create course category directory: {} because of {}", + category_dir.to_string_lossy(), + e + )) + })?; - for entry in fs::read_dir(&courses_path)? { - let entry = entry?; - let path = entry.path(); + for task in &category.tasks { + let task_dir = category_dir.join(&task.id); + fs::create_dir_all(&task_dir).await.map_err(|e| { + FileSystemError::TaskFolderError(format!( + "Failed to create course task directory: {} because of {}", + task_dir.to_string_lossy(), + e + )) + })?; - if let Some(folder_name) = path.file_name().and_then(|n| n.to_str()) { - if path.is_dir() && folder_name == course_string.as_str() { - let config_path = path.join("config.toml"); - if config_path.exists() { - // course config need to be valid - let config = read_toml(config_path) - .expect("All course configs should be valid so they should read correctly"); - return Ok(config); - } - } + let output_path = task_dir.join("output"); + fs::create_dir_all(&output_path).await.map_err(|e| { + FileSystemError::OutputFolderError(format!( + "Failed to create course task output directory: {} because of {}", + output_path.to_string_lossy(), + e + )) + })?; + // Check for entrypont? } } - Err(std::io::Error::new( - std::io::ErrorKind::NotFound, - format!("Course with id {course_id} not found"), - )) -} - -pub async fn find_task( - course: ModuleConfiguration, - task_id: String, -) -> Result { - // Searches for a task in the course configuration - let task = course.get_task_by_id(task_id.as_str()); - if let Some(task) = task { - return Ok(task.clone()); + Ok(config) +} + +/// Precomputes the JSON cache for the server based on the provided course structures. +/// Cache holds json API responses for courses, categories, and tasks. +pub async fn precompute_json_cache(courses: Vec) -> Result { + let precomputed_courses_json = { + let courses_response: Vec = courses + .iter() + .map(|c| CourseResponse { + id: c.id.to_string(), + name: c.name.clone(), + description: c.description.clone(), + }) + .collect(); + Arc::new(Json(courses_response)) + }; + + let mut category_json: HashMap>>> = HashMap::new(); + let mut task_json: HashMap<(String, String), Arc>>> = HashMap::new(); + for course in &courses { + let course_id_str = course.id.to_string(); + + let categories_response: Vec = course + .categories + .iter() + .map(|cat| CategoryResponse { + name: cat.name.clone(), + number: cat.number, + }) + .collect(); + category_json.insert(course_id_str.clone(), Arc::new(Json(categories_response))); + + for category in &course.categories { + let tasks_response: Vec = category + .tasks + .iter() + .map(|task| TaskResponse { + id: task.id.clone(), + name: task.name.clone(), + description: task.description.clone(), + }) + .collect(); + + task_json.insert( + (course_id_str.clone(), category.name.clone()), + Arc::new(Json(tasks_response)), + ); + } } - Err(std::io::Error::new( - std::io::ErrorKind::NotFound, - format!("Task with id {task_id} not found in course"), - )) -} - -pub async fn compareanswer( - _course_id: Uuid, - _task_id: String, - _user_id: Uuid, - _answer: String, -) -> Result { - // Compares the user's answer with the correct answer from database - Ok(false) // Placeholder for actual comparison logic + Ok(Cache { + courses: precomputed_courses_json, + categories: category_json, + tasks: task_json, + }) +} + +/// Builds a course cache for the server based on course structures. +/// Cache holds data data about courses, categories, and tasks that is used to build the API responses. +/// +/// # Returns +/// CourseCache on success, or FileSystemError if any IO or parsing errors occur. +/// +pub async fn build_course_cache( + config: ModuleConfiguration, +) -> Result { + let categories = config + .categories + .iter() + .map(|cat| CategoryCache { + name: cat.name.clone(), + number: cat.number, + tasks: cat + .tasks + .iter() + .map(|task| TaskCache { + id: task.id.clone(), + name: task.name.clone(), + description: task.description.clone(), + }) + .collect(), + }) + .collect(); + + Ok(CourseCache { + id: config.identifier, + name: config.name, + description: config.description, + categories, + }) +} + +pub async fn handler_404() -> impl IntoResponse { + let body = Json(json!({ + "error": "Not Found", + "message": "The requested resource was not found on this server." + })); + + (StatusCode::NOT_FOUND, body) } diff --git a/ainigma-backend/src/bin/main.rs b/ainigma-backend/src/bin/main.rs new file mode 100644 index 0000000..0b6f5c6 --- /dev/null +++ b/ainigma-backend/src/bin/main.rs @@ -0,0 +1,92 @@ +use ainigma_backend::{ + backend::{ + DataStructureStatus, categories_handler, download_file_handler, generate_data_structure, + get_task_metadata, handler_404, list_courses_handler, tasks_handler, + }, + errors::filesystem::FileSystemError, +}; +use axum::{Extension, Json, Router, routing::get}; +use std::net::SocketAddr; +use std::sync::Arc; +use tracing_subscriber::{EnvFilter, fmt}; + +#[tokio::main] +async fn main() -> Result<(), Box> { + init_logging(); + tracing::info!("Server is starting..."); + + tracing::info!("Generating/Checking server data structure..."); + let data_structure_result = match generate_data_structure().await { + Ok(result) => result, + Err(e) => { + tracing::error!("Failed to initialize data structure: {}", e); + return Err(Box::new(e)); + } + }; + match data_structure_result.status { + DataStructureStatus::EmptyCoursesFolder => { + tracing::warn!( + "Inital data structure made successfully. Please add courses to the server." + ); + return Ok(()); + } + DataStructureStatus::CoursesLoaded => { + tracing::info!("Data structure working with courses loaded.") + } + } + + let cache = Arc::new(data_structure_result.cache); + + let courses_router = Router::new() + .route("/", get(list_courses_handler)) + .route("/{course_id}", get(categories_handler)) + .route("/{course_id}/{category_name}", get(tasks_handler)) + .route( + "/{course_id}/{category_name}/{task_id}/{uuid}", + get(get_task_metadata), + ) + .route( + "/{course_id}/{category_name}/{task_id}/{uuid}/download/{file_name}", + get(download_file_handler), + ); + + let app = Router::new() + .nest("/courses", courses_router) + .route("/health", get(health_check)) + .layer(Extension(cache.clone())) + .fallback(handler_404); + + let addr = SocketAddr::from(([127, 0, 0, 1], 8080)); + tracing::info!("Listening on {}", addr); + let listener = tokio::net::TcpListener::bind(&addr).await.map_err(|e| { + FileSystemError::BindError(format!("Could not bind to address {addr}: {e}")) + })?; + axum::serve(listener, app) + .await + .map_err(|e| FileSystemError::ServeError(format!("Server failed: {e}")))?; + Ok(()) +} + +fn init_logging() { + let log_level = std::env::var("RUST_LOG") + .ok() + .unwrap_or_else(|| "info".to_string()); + + let filter_layer = EnvFilter::try_new(log_level).unwrap_or_else(|_| EnvFilter::new("info")); + + let fmt_layer = fmt::Subscriber::builder() + .with_env_filter(filter_layer) + .with_target(false) + .with_thread_ids(true) + .with_line_number(true) + .with_file(true) + .compact() + .finish(); + + tracing::subscriber::set_global_default(fmt_layer) + .expect("Failed to set global tracing subscriber"); +} + +async fn health_check() -> Json<&'static str> { + Json("OK") +} diff --git a/ainigma-backend/src/db.rs b/ainigma-backend/src/db.rs new file mode 100644 index 0000000..243dc74 --- /dev/null +++ b/ainigma-backend/src/db.rs @@ -0,0 +1,129 @@ +use serde_json::Value; +use sqlx::PgPool; +use sqlx::types::Uuid; + +pub struct CourseInput { + pub id: Uuid, + pub name: String, + pub description: String, +} + +pub struct CategoryInput { + pub course_id: Uuid, + pub name: String, + pub number: i32, +} + +pub struct TaskInput { + pub id: String, + pub course_id: Uuid, + pub category_name: String, + pub name: String, + pub description: String, + pub points: Option, +} + +pub struct TaskStageInput { + pub id: String, + pub course_id: Uuid, + pub category_name: String, + pub task_id: String, + pub name: String, + pub description: String, + pub weight: i32, + pub flag: Value, +} + +pub struct UserStageProgressInput { + pub user_id: Uuid, + pub course_id: Uuid, + pub category_name: String, + pub task_id: String, + pub stage_id: String, + pub completed: bool, + pub score: Option, +} + +pub async fn insert_course(pool: &PgPool, course: &CourseInput) -> Result<(), sqlx::Error> { + sqlx::query( + "INSERT INTO courses(id, name, description) VALUES ($1, $2, $3) ON CONFLICT (id) DO NOTHING" + ) + .bind(course.id) + .bind(&course.name) + .bind(&course.description) + .execute(pool) + .await?; + Ok(()) +} + +// Insert category +pub async fn insert_category(pool: &PgPool, category: &CategoryInput) -> Result<(), sqlx::Error> { + sqlx::query( + "INSERT INTO categories(course_id, name, number) VALUES ($1, $2, $3) ON CONFLICT (course_id, name) DO NOTHING" + ) + .bind(category.course_id) + .bind(&category.name) + .bind(category.number) + .execute(pool) + .await?; + Ok(()) +} + +// Insert task +pub async fn insert_task(pool: &PgPool, task: &TaskInput) -> Result<(), sqlx::Error> { + sqlx::query( + "INSERT INTO tasks(id, course_id, category_name, name, description, points) + VALUES ($1, $2, $3, $4, $5, $6) + ON CONFLICT (course_id, category_name, id) DO NOTHING", + ) + .bind(&task.id) + .bind(task.course_id) + .bind(&task.category_name) + .bind(&task.name) + .bind(&task.description) + .bind(task.points) + .execute(pool) + .await?; + Ok(()) +} + +// Insert task stage +pub async fn insert_task_stage(pool: &PgPool, stage: &TaskStageInput) -> Result<(), sqlx::Error> { + sqlx::query( + "INSERT INTO task_stages(id, course_id, category_name, task_id, name, description, weight, flag) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8) + ON CONFLICT (course_id, category_name, task_id, id) DO NOTHING" + ) + .bind(&stage.id) + .bind(stage.course_id) + .bind(&stage.category_name) + .bind(&stage.task_id) + .bind(&stage.name) + .bind(&stage.description) + .bind(stage.weight) + .bind(&stage.flag) + .execute(pool) + .await?; + Ok(()) +} + +pub async fn insert_user_stage_progress( + pool: &PgPool, + progress: &UserStageProgressInput, +) -> Result<(), sqlx::Error> { + sqlx::query( + "INSERT INTO user_stage_progress(user_id, course_id, category_name, task_id, stage_id, completed, score) + VALUES ($1, $2, $3, $4, $5, $6, $7) + ON CONFLICT (user_id, course_id, category_name, task_id, stage_id) DO NOTHING" + ) + .bind(progress.user_id) + .bind(progress.course_id) + .bind(&progress.category_name) + .bind(&progress.task_id) + .bind(&progress.stage_id) + .bind(progress.completed) + .bind(progress.score) + .execute(pool) + .await?; + Ok(()) +} diff --git a/ainigma-backend/src/errors/filesystem.rs b/ainigma-backend/src/errors/filesystem.rs new file mode 100644 index 0000000..ba15184 --- /dev/null +++ b/ainigma-backend/src/errors/filesystem.rs @@ -0,0 +1,31 @@ +use thiserror::Error; + +#[derive(Error, Debug)] +pub enum FileSystemError { + #[error("Not found")] + NotFound, + #[error("Read error: {0}")] + ReadError(String), + #[error("Data folder error: {0}")] + DataFolderError(String), + #[error("Courses folder error: {0}")] + CourseFolderError(String), + #[error("Category folder error: {0}")] + CategoryFolderError(String), + #[error("Task folder error: {0}")] + TaskFolderError(String), + #[error("Output folder error: {0}")] + OutputFolderError(String), + #[error("Config error: {0}")] + ConfigError(String), + #[error("Error parsing the correct error: {0}")] + JoinError(String), + #[error("Failed to create course cache: {0}")] + CacheError(String), + #[error("Failed to bind to address {0}")] + BindError(String), + #[error("Server failed: {0}")] + ServeError(String), + #[error("Initialization error: {0}")] + InitializationError(String), +} diff --git a/ainigma-backend/src/errors/mod.rs b/ainigma-backend/src/errors/mod.rs new file mode 100644 index 0000000..a286318 --- /dev/null +++ b/ainigma-backend/src/errors/mod.rs @@ -0,0 +1 @@ +pub mod filesystem; diff --git a/ainigma-backend/src/lib.rs b/ainigma-backend/src/lib.rs index fceb141..87e582f 100644 --- a/ainigma-backend/src/lib.rs +++ b/ainigma-backend/src/lib.rs @@ -1 +1,4 @@ pub mod backend; +pub mod db; + +pub mod errors; diff --git a/ainigma-backend/src/main.rs b/ainigma-backend/src/main.rs deleted file mode 100644 index 6d9c61e..0000000 --- a/ainigma-backend/src/main.rs +++ /dev/null @@ -1,6 +0,0 @@ -use std::error::Error; - -#[tokio::main] -async fn main() -> Result<(), Box> { - Ok(()) -} diff --git a/ainigma-backend/tests/data/courses/01908498-ac98-708d-b886-b6f2747ef785/Network_Security_Fundamentals/task001/build.sh b/ainigma-backend/tests/data/courses/01908498-ac98-708d-b886-b6f2747ef785/Network_Security_Fundamentals/task001/build.sh new file mode 100644 index 0000000..f22461a --- /dev/null +++ b/ainigma-backend/tests/data/courses/01908498-ac98-708d-b886-b6f2747ef785/Network_Security_Fundamentals/task001/build.sh @@ -0,0 +1,18 @@ +#!/bin/sh +echo "PWD: $(pwd)" +set -e + +FLAG=$(jq -r '.outputs[0].stage_flags[0].user_derived.suffix' "$BUILD_MANIFEST") +OUTPUT_DIR=$(jq -r '.outputs[0].task_instance_dir' "$BUILD_MANIFEST") + +cat << EOF > "$OUTPUT_DIR/secret.sh" +#!/bin/bash +FLAG="$FLAG" +echo "Flag: \\flag{\$FLAG}" +EOF + +chmod +x "$OUTPUT_DIR/secret.sh" + +cat << EOF > "$OUTPUT_DIR/readme.txt" +This is a test task that includes a flag script. +EOF \ No newline at end of file diff --git a/ainigma-backend/tests/data/courses/01908498-ac98-708d-b886-b6f2747ef785/Network_Security_Fundamentals/task002/output/01908498-ac98-708d-b886-b6f2747ef785/readme.txt b/ainigma-backend/tests/data/courses/01908498-ac98-708d-b886-b6f2747ef785/Network_Security_Fundamentals/task002/output/01908498-ac98-708d-b886-b6f2747ef785/readme.txt new file mode 100644 index 0000000..01e7796 --- /dev/null +++ b/ainigma-backend/tests/data/courses/01908498-ac98-708d-b886-b6f2747ef785/Network_Security_Fundamentals/task002/output/01908498-ac98-708d-b886-b6f2747ef785/readme.txt @@ -0,0 +1 @@ +File download success \ No newline at end of file diff --git a/ainigma-backend/tests/data/courses/01908498-ac98-708d-b886-b6f2747ef785/config.toml b/ainigma-backend/tests/data/courses/01908498-ac98-708d-b886-b6f2747ef785/config.toml new file mode 100644 index 0000000..570388b --- /dev/null +++ b/ainigma-backend/tests/data/courses/01908498-ac98-708d-b886-b6f2747ef785/config.toml @@ -0,0 +1,30 @@ +identifier = "01908498-ac98-708d-b886-b6f2747ef785" +name = "Cybersecurity" +description = "A test course" +version = "0.0.1" + +[[categories]] +number = 1 +name = "Network_Security_Fundamentals" + +[[categories.tasks]] +id = "task001" +name = "Challenge 5" +description = "Try harder." +points = 2.0 +stages = [{ flag = { kind = "user_derived" } }] + +[categories.tasks.build] +directory = "/tests/data/courses/01908498-ac98-708d-b886-b6f2747ef785/Network_Security_Fundamentals/task001" +builder = { shell = { entrypoint = "build.sh" } } +enabled_modes = ["sequential"] + +[[categories.tasks.build.output]] +kind = { readme = "readme.txt" } + +[[categories.tasks.build.output]] +kind = { resource = "secret.sh" } + +[flag_config] +user_derived = { secret = "testsecret" } +rng_seed = { secret = "seed" } \ No newline at end of file diff --git a/ainigma-backend/tests/quick_dev.rs b/ainigma-backend/tests/quick_dev.rs new file mode 100644 index 0000000..9a55994 --- /dev/null +++ b/ainigma-backend/tests/quick_dev.rs @@ -0,0 +1,192 @@ +use std::sync::Arc; + +use ainigma_backend::backend::{ + Cache, categories_handler, download_file_handler, generate_data_structure, get_task_metadata, + handler_404, list_courses_handler, tasks_handler, +}; +use axum::{Extension, Json, Router, routing::get}; +use reqwest::Client; +use tokio::{net::TcpListener, task}; + +pub fn create_app(cache: Arc) -> Router { + let courses_router = Router::new() + .route("/", get(list_courses_handler)) + .route("/{course_id}", get(categories_handler)) + .route("/{course_id}/{category_name}", get(tasks_handler)) + .route( + "/{course_id}/{category_name}/{task_id}/{uuid}", + get(get_task_metadata), + ) + .route( + "/{course_id}/{category_name}/{task_id}/{uuid}/download/{file_name}", + get(download_file_handler), + ); + + Router::new() + .nest("/courses", courses_router) + .route("/health", get(health_check)) + .layer(Extension(cache.clone())) + .fallback(handler_404) +} + +async fn health_check() -> Json<&'static str> { + Json("OK") +} + +#[tokio::test] +async fn test_full_server_with_dummy_task() { + // Setup course data + let _ = tracing_subscriber::fmt() + .with_env_filter("debug") + .try_init(); + + let cwd = std::env::current_dir().expect("current dir"); + tracing::info!("Current working directory: {:?}", cwd); + + let data_path = cwd.join("tests/data"); + tracing::info!("Data path: {:?}", data_path); + + let output_dir = cwd.join("tests/data/courses/01908498-ac98-708d-b886-b6f2747ef785/Network_Security_Fundamentals/task001/output/01908498-ac98-708d-b886-b6f2747ef785"); + + unsafe { + std::env::set_var("AINIGMA_DATA_PATH", data_path); + } + let result = generate_data_structure().await.expect("structure"); + let cache = Arc::new(result.cache); + let app = create_app(cache); + + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + + let server = task::spawn(async move { + axum::serve(listener, app).await.unwrap(); + }); + + let url = format!( + "http://{addr}/courses/01908498-ac98-708d-b886-b6f2747ef785/Network_Security_Fundamentals/task001/01908498-ac98-708d-b886-b6f2747ef785" + ); + + let res = Client::new() + .get(&url) + .send() + .await + .expect("request failed"); + + let status = res.status(); + let body = res + .text() + .await + .unwrap_or_else(|_| "".to_string()); + + if !status.is_success() { + eprintln!("Request failed: status = {status}, body = {body}"); + } + assert!(status.is_success()); + + println!("Response body:\n{body}"); + + let json: serde_json::Value = + serde_json::from_str(&body).expect("Failed to parse JSON response"); + + assert_eq!(json["instructions"], "No instructions available."); + + let files = json["files"].as_array().expect("files should be an array"); + let filenames: Vec<&str> = files + .iter() + .map(|file| file["name"].as_str().unwrap()) + .collect(); + + let expected_files = vec!["readme.txt", "secret.sh"]; + + for expected in expected_files { + assert!( + filenames.contains(&expected), + "Expected file '{expected}' missing in response" + ); + } + + if output_dir.exists() { + tracing::info!("Cleaning output directory after test"); + tokio::fs::remove_dir_all(&output_dir) + .await + .expect("Failed to clean output directory"); + } + + // Optionally shutdown the server by dropping + drop(server); +} + +#[tokio::test] +async fn test_server_download() { + let _ = tracing_subscriber::fmt() + .with_env_filter("debug") + .try_init(); + + let cwd = std::env::current_dir().expect("current dir"); + tracing::info!("Current working directory: {:?}", cwd); + + let data_path = cwd.join("tests/data"); + tracing::info!("Data path: {:?}", data_path); + + unsafe { + std::env::set_var("AINIGMA_DATA_PATH", data_path); + } + + let result = generate_data_structure().await.expect("structure"); + let cache = Arc::new(result.cache); + let app = create_app(cache); + + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + + let server = task::spawn(async move { + axum::serve(listener, app).await.unwrap(); + }); + + let course_id = "01908498-ac98-708d-b886-b6f2747ef785"; + let category_name = "Network_Security_Fundamentals"; + let task_id = "task002"; + let uuid = "01908498-ac98-708d-b886-b6f2747ef785"; + let file_name = "readme.txt"; + + let url = format!( + "http://{addr}/courses/{course_id}/{category_name}/{task_id}/{uuid}/download/{file_name}" + ); + + let res = Client::new() + .get(&url) + .send() + .await + .expect("request failed"); + + assert!(res.status().is_success(), "Download failed"); + + let content_type = res + .headers() + .get("content-type") + .expect("Missing Content-Type header") + .to_str() + .unwrap() + .to_string(); + + let body = res.bytes().await.expect("Failed to read response body"); + + let path = cwd.join(format!( + "tests/data/courses/{course_id}/{category_name}/{task_id}/output/{uuid}/{file_name}" + )); + let expected_mime = mime_guess::from_path(&path); + + let guess = expected_mime.first_or_octet_stream(); + + let mime_str = guess.essence_str().to_string(); + + let content = String::from_utf8_lossy(&body); + + assert_eq!(content_type, mime_str, "MIME type mismatch"); + + let expected_body = "File download success"; + + assert_eq!(content, expected_body, "Downloaded file contents mismatch"); + + drop(server) +} diff --git a/ainigma/Cargo.toml b/ainigma/Cargo.toml index d1ad844..827efe8 100644 --- a/ainigma/Cargo.toml +++ b/ainigma/Cargo.toml @@ -12,6 +12,7 @@ path = "src/bin/cli.rs" tokio = { version = "1.44", default-features = false, features = [ "macros", "rt-multi-thread", + "process" ] } # Crypto diff --git a/ainigma/src/bin/cli.rs b/ainigma/src/bin/cli.rs index cc5b951..c81b6b3 100644 --- a/ainigma/src/bin/cli.rs +++ b/ainigma/src/bin/cli.rs @@ -139,8 +139,7 @@ fn output_dir_selection(output_dir: Option<&PathBuf>) -> Result dir, Err(error) => Err(BuildError::TemporaryDirectoryFail(format!( - "Error when creating a temporal directory {}", - error + "Error when creating a temporal directory {error}" )))?, }; tracing::info!( @@ -344,7 +343,7 @@ fn main() -> std::process::ExitCode { } Commands::Validate { task } => { tracing::info!("Validating the configuration file..."); - println!("{:#?}", config); + println!("{config:#?}"); tracing::info!( "Configuration file is valid. Creating '{}' file in the current directory.", DEFAULT_BUILD_MANIFEST @@ -449,8 +448,7 @@ fn parallel_task_build<'a>( if failure_flag.load(Ordering::SeqCst) { tracing::info!("Skipping variant {} due to failure in another variant", i); return Err(BuildError::ThreadError(format!( - "Stopping variant {} due to failure in another thread", - i + "Stopping variant {i} due to failure in another thread" ))); } @@ -498,13 +496,12 @@ fn parallel_task_build<'a>( } } Err(panic_error) => { - let error_msg = format!("Thread panicked: {:?}", panic_error); + let error_msg = format!("Thread panicked: {panic_error:?}"); tracing::error!("{}", error_msg); if first_error.is_none() { first_error = Some(BuildError::ThreadError(format!( - "Error when joining thread: {}", - error_msg + "Error when joining thread: {error_msg}" ))); } } diff --git a/ainigma/src/build_process.rs b/ainigma/src/build_process.rs index f2c91e3..4baa2ca 100644 --- a/ainigma/src/build_process.rs +++ b/ainigma/src/build_process.rs @@ -1,6 +1,8 @@ use std::collections::HashMap; use std::fs; use std::path::{Path, PathBuf}; +use tokio::process::Command as TokioCommand; + // use tracing::instrument; use uuid::Uuid; @@ -100,8 +102,7 @@ impl IntermediateOutput { .count(); if readme_count != 1 { return Err(BuildError::OutputVerificationFailed(format!( - "Expected exactly one readme file, found {}", - readme_count + "Expected exactly one readme file, found {readme_count}" ))); } Ok(()) @@ -195,8 +196,7 @@ fn get_build_info( } } Err(format!( - "Build information for task with id {} not found!", - task_id + "Build information for task with id {task_id} not found!" )) } /// Couple output items together, so we link points to the correct output @@ -289,6 +289,57 @@ fn run_subprocess( } } +async fn run_subprocess_async( + program: &str, + args: Vec<&str>, + task_directory: &Path, + build_manifest: &mut TaskBuildContainer<'_>, + build_envs: HashMap, +) -> Result<(), BuildError> { + tracing::debug!( + "Running subprocess: {} with args: {:?} in folder {}", + program, + args, + &task_directory.display() + ); + let output = TokioCommand::new(program) + .args(args) + .envs(build_envs) // Use merged environment + .current_dir(task_directory) + .output() + .await + .map_err(|e| { + BuildError::ShellSubprocessError(format!( + "The build process of task {} failed prematurely: {}", + build_manifest.task.id, e + )) + })?; + + if output.status.success() { + let stdout = String::from_utf8_lossy(&output.stdout); + for line in stdout.lines() { + tracing::info!("{}", line); + } + build_manifest.validate_output()?; + + // If the task has a seed-based flag, we must capture the resulting flag from the process output + // Stored into the file flags.json by default, using same key as the passed environment variable + map_rng_seed_to_flag( + &mut build_manifest.outputs, + &build_manifest.basedir, + build_manifest.task, + )?; + + Ok(()) + } else { + Err(BuildError::ShellSubprocessError(format!( + "The build process for task {} failed with non-zero exit code. Error: {}", + build_manifest.task.id, + std::str::from_utf8(&output.stderr).unwrap_or("Unable to read stderr") + ))) + } +} + pub fn build_batch<'a>( module_config: &'a ModuleConfiguration, task_config: &'a Task, @@ -457,8 +508,7 @@ pub fn build_sequential<'a>( if !output_directory.exists() { fs::create_dir_all(output_directory).map_err(|e| { BuildError::InvalidOutputDirectory(format!( - "Failed to create the base output directory: {}", - e + "Failed to create the base output directory: {e}" )) })?; } @@ -525,24 +575,26 @@ pub fn build_sequential<'a>( Ok(build_manifest.outputs.remove(0)) } -/// Build task function for serverside use +/// Build task function for serverside use containing the task folder pub async fn build_task<'a>( module_config: &'a ModuleConfiguration, + task_directory: &Path, task_id: &str, uuid: Uuid, ) -> Result, BuildError> { + tracing::debug!( + "Building task {} with UUID {} in directory {}", + task_id, + uuid, + task_directory.display() + ); // Task has to exist if let Some(task) = module_config.get_task_by_id(task_id) { - let path = task.build.directory.clone(); // path must exist checked in the module configuration - // check if output directory exists - let output_dir = path.join("output"); - // if not, create it - tokio::fs::create_dir_all(&output_dir) - .await - .map_err(|e| BuildError::InvalidOutputDirectory(e.to_string()))?; + // output directory must exists // There isnt a student folder inside the output directory it is checked before - let student_output_dir = output_dir.join(uuid.to_string()); + let relative_student_output_dir = PathBuf::from("output").join(uuid.to_string()); + let student_output_dir = task_directory.join(&relative_student_output_dir); // TODO: Add optional execution where student files are not saved tokio::fs::create_dir_all(&student_output_dir) .await @@ -561,7 +613,7 @@ pub async fn build_task<'a>( IntermediateOutput::new(uuid, flags, student_output_dir.clone(), expected_outputs); let mut build_container = TaskBuildContainer::new( - student_output_dir.to_path_buf(), + task_directory.to_path_buf(), task, vec![intermediate_output], false, @@ -586,15 +638,17 @@ pub async fn build_task<'a>( tracing::debug!( "Running shell command: {} in directory: {}", entrypoint.entrypoint, - student_output_dir.display() + task_directory.display() ); - run_subprocess( + run_subprocess_async( "sh", vec![&entrypoint.entrypoint], + task_directory, &mut build_container, build_envs, - )?; + ) + .await?; } Builder::Nix(_) => todo!("Nix builder not implemented"), } diff --git a/ainigma/src/config.rs b/ainigma/src/config.rs index 2f7fc6f..a690b33 100644 --- a/ainigma/src/config.rs +++ b/ainigma/src/config.rs @@ -9,6 +9,7 @@ use std::fs::File; use std::io::Read; use std::path::{Path, PathBuf}; use std::str::FromStr; +use tokio::io::AsyncReadExt; use uuid::Uuid; const DEFAULT_NIX_FILENAME: &str = "flake.nix"; @@ -30,7 +31,7 @@ fn random_hex_secret() -> String { String::with_capacity(random_bytes.len() * 2), |mut acc, b| { use std::fmt::Write; - let _ = write!(acc, "{:02x}", b); + let _ = write!(acc, "{b:02x}"); acc }, ) @@ -339,7 +340,7 @@ where { let modes = Vec::::deserialize(deserializer)?; NonEmptyBuildModes::new(modes) - .map_err(|err| serde::de::Error::custom(format!("Error in 'enabled_modes' field: {}", err))) + .map_err(|err| serde::de::Error::custom(format!("Error in 'enabled_modes' field: {err}"))) } #[derive(Debug, Serialize, Deserialize, Clone)] @@ -684,13 +685,34 @@ pub fn check_task(task: &Task) -> Result { //TODO: Add warnings for unspecified fields pub fn read_check_toml(filepath: &OsStr) -> Result { let mut file = File::open(filepath).map_err(|err| ConfigError::TomlParseError { - message: format!("Failed to open file: {}", err), + message: format!("Failed to open file: {err}"), })?; let mut file_content = String::new(); file.read_to_string(&mut file_content) .map_err(|err| ConfigError::TomlParseError { - message: format!("Failed to read file content: {}", err), + message: format!("Failed to read file content: {err}"), + })?; + let module_config = + toml::from_str(&file_content).map_err(|err| ConfigError::TomlParseError { + message: err.to_string(), + })?; + check_toml(module_config) +} + +pub async fn server_read_check_toml(filepath: &OsStr) -> Result { + let mut file = + tokio::fs::File::open(filepath) + .await + .map_err(|err| ConfigError::TomlParseError { + message: format!("Failed to open file: {err}"), + })?; + + let mut file_content = String::new(); + file.read_to_string(&mut file_content) + .await + .map_err(|err| ConfigError::TomlParseError { + message: format!("Failed to read file content: {err}"), })?; let module_config = toml::from_str(&file_content).map_err(|err| ConfigError::TomlParseError { @@ -700,7 +722,7 @@ pub fn read_check_toml(filepath: &OsStr) -> Result Result { +pub async fn read_toml(filepath: PathBuf) -> Result { let mut file = File::open(filepath).map_err(|err| ConfigError::TomlParseError { message: format!("Failed to open file: {err}"), })?; diff --git a/ainigma/src/flag_generator.rs b/ainigma/src/flag_generator.rs index 51f4f35..a856fce 100644 --- a/ainigma/src/flag_generator.rs +++ b/ainigma/src/flag_generator.rs @@ -127,7 +127,7 @@ impl FlagUnit { fn rng_flag(identifier: String, lenght: u8) -> Self { let suffix = pure_random_flag(lenght); - let encased = format!("flag{{{}:{}}}", identifier, suffix); + let encased = format!("flag{{{identifier}:{suffix}}}"); FlagUnit { identifier, suffix, @@ -153,7 +153,7 @@ impl FlagUnit { Ok(flag) => flag, Err(_error) => panic!("Error generating flag"), }; - let encased = format!("flag{{{}:{}}}", identifier, flag_suffix); + let encased = format!("flag{{{identifier}:{flag_suffix}}}"); FlagUnit { identifier, @@ -197,7 +197,7 @@ fn user_derived_flag( let result = mac.finalize(); let bytes = result.into_bytes(); - Ok(format!("{:x}", bytes)) + Ok(format!("{bytes:x}")) } } } @@ -216,7 +216,7 @@ fn compare_hmac( let result = mac.finalize(); let bytes = result.into_bytes(); - let s = format!("{:x}", bytes); + let s = format!("{bytes:x}"); Ok(s == hmac) } @@ -239,7 +239,7 @@ mod tests { let taskid2 = "task1".to_string(); let hash = user_derived_flag(&Algorithm::HMAC_SHA3_256, &id, &secret, &taskid).expect("error"); - print!("{}", hash); + print!("{hash}"); assert!(compare_hmac(hash, id, secret2, taskid2).expect("should work")) } @@ -259,16 +259,16 @@ mod tests { let answer2 = user_derived_flag(&Algorithm::HMAC_SHA3_256, &id, &secret, &taskid).expect("works"); - println!("{}", answer1); - println!("{}", answer2); + println!("{answer1}"); + println!("{answer2}"); let flag = Flag::new_user_flag(prefix, &Algorithm::HMAC_SHA3_256, &secret2, &taskid2, &id); let result = flag.flag_string(); - println!("{}", result); + println!("{result}"); let flag2 = Flag::new_user_flag(prefix2, &Algorithm::HMAC_SHA3_256, &secret3, &taskid3, &id); let result2 = flag2.flag_string(); - println!("{}", result2); + println!("{result2}"); } #[test] @@ -289,6 +289,6 @@ mod tests { let string = flag.flag_string(); let string2 = flag2.flag_string(); let string3 = flag3.flag_string(); - println!("{} {} {}", string, string2, string3) + println!("{string} {string2} {string3}") } } diff --git a/ainigma/src/moodle.rs b/ainigma/src/moodle.rs index 9a8640a..bee3482 100644 --- a/ainigma/src/moodle.rs +++ b/ainigma/src/moodle.rs @@ -84,7 +84,7 @@ pub fn create_exam( }; question .add_answers(answers) - .map_err(|e| io::Error::other(format!("Error: {:?}", e)))?; + .map_err(|e| io::Error::other(format!("Error: {e:?}")))?; questions.push(question.into()); } None => { @@ -98,7 +98,7 @@ pub fn create_exam( let categories = vec![category.into()]; quiz.set_categories(categories); quiz.to_xml(filename) - .map_err(|e| io::Error::other(format!("Error: {:?}", e)))?; + .map_err(|e| io::Error::other(format!("Error: {e:?}")))?; Ok(()) } diff --git a/ainigma/src/storages/storage.rs b/ainigma/src/storages/storage.rs index a6e5aeb..d7d1cc6 100644 --- a/ainigma/src/storages/storage.rs +++ b/ainigma/src/storages/storage.rs @@ -70,8 +70,7 @@ impl FileObjects { if file_map.contains_key(&file_name) { return Err(FileObjectError::FilesNotUnique(format!( - "File {} already exists in the list", - file_name + "File {file_name} already exists in the list" ))); } file_map.insert(file_name, file); diff --git a/ainigma/tests/batch_build.rs b/ainigma/tests/batch_build.rs index 5696cd5..514ca9e 100644 --- a/ainigma/tests/batch_build.rs +++ b/ainigma/tests/batch_build.rs @@ -44,7 +44,7 @@ fn batch_simple_validate() -> Result<(), Box> { let manifest = temp_dir.path().join(DEFAULT_BUILD_MANIFEST); // print manifest file content let manifest_content = std::fs::read_to_string(&manifest)?; - println!("Manifest content: {}", manifest_content); + println!("Manifest content: {manifest_content}"); assert!( manifest.exists(),