hyperion/effects/
definition.rs

1use std::path::{Path, PathBuf};
2use std::sync::Arc;
3
4use serde::{Deserialize, Serialize};
5use thiserror::Error;
6use tokio::fs;
7use tracing::error;
8
9#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
10#[serde(deny_unknown_fields)]
11pub struct EffectDefinition {
12    /// Friendly name of the effect
13    pub name: String,
14    /// Path to the effect definition file
15    #[serde(skip)]
16    pub file: PathBuf,
17    /// Path to the effect script
18    pub script: String,
19    /// Arguments to the effect script
20    #[serde(default)]
21    pub args: serde_json::Value,
22    /// Path this definition is located in
23    #[serde(skip)]
24    base_path: Arc<PathBuf>,
25}
26
27#[derive(Debug, Error)]
28pub enum EffectDefinitionError {
29    /// i/o error
30    #[error(transparent)]
31    Io(#[from] std::io::Error),
32    /// Invalid effect Definitionification path
33    #[error("invalid effect Definitionification path")]
34    InvalidPath,
35    /// JSON error
36    #[error(transparent)]
37    Json(#[from] serde_json::Error),
38}
39
40impl EffectDefinition {
41    pub async fn read_dir(path: impl AsRef<Path>) -> Result<Vec<Self>, EffectDefinitionError> {
42        let base_path = Arc::new(path.as_ref().to_owned());
43        let mut definitions = Vec::new();
44
45        let mut read_dir = fs::read_dir(path.as_ref()).await?;
46        loop {
47            let result = read_dir.next_entry().await;
48            match result {
49                Ok(None) => {
50                    break;
51                }
52                Ok(Some(entry)) => {
53                    let path = entry.path();
54                    if path.extension().and_then(std::ffi::OsStr::to_str) != Some("json") {
55                        continue;
56                    }
57
58                    match Self::from_file(&path, base_path.clone()).await {
59                        Ok(definition) => {
60                            definitions.push(definition);
61                        }
62                        Err(err) => {
63                            error!(path = %path.display(), error = %err, "error reading effect definition");
64                        }
65                    }
66                }
67                Err(err) => {
68                    error!(error = %err, "error reading effect definition directory");
69                }
70            }
71        }
72
73        Ok(definitions)
74    }
75
76    pub async fn read_file(path: impl AsRef<Path>) -> Result<Self, EffectDefinitionError> {
77        let path = path.as_ref();
78
79        Self::from_file(
80            path,
81            path.parent()
82                .ok_or(EffectDefinitionError::InvalidPath)?
83                .to_owned()
84                .into(),
85        )
86        .await
87    }
88
89    async fn from_file(
90        path: &Path,
91        base_path: Arc<PathBuf>,
92    ) -> Result<Self, EffectDefinitionError> {
93        // Read file contents
94        let json = fs::read(path).await?;
95
96        // Parse
97        let mut this: Self = serde_json::from_slice(&json)?;
98
99        // Set path
100        this.file = path
101            .strip_prefix(&*base_path)
102            .map(|path| path.to_owned())
103            .unwrap_or_else(|_| path.to_owned());
104
105        // Set base path
106        this.base_path = base_path;
107
108        Ok(this)
109    }
110
111    pub fn script_path(&self) -> Result<PathBuf, EffectDefinitionError> {
112        let mut result = (*self.base_path).clone();
113        let subpath = PathBuf::from(&self.script);
114
115        // Prevent traversal attacks
116        for component in subpath.components() {
117            match component {
118                std::path::Component::CurDir => {
119                    // Ignore
120                }
121                std::path::Component::Normal(component) => {
122                    result.push(component);
123                }
124                _ => {
125                    return Err(EffectDefinitionError::InvalidPath);
126                }
127            }
128        }
129
130        Ok(result)
131    }
132}
133
134#[cfg(test)]
135mod tests {
136    use super::*;
137
138    #[tokio::test]
139    async fn load_builtin_effects() {
140        let paths = crate::global::Paths::new(None).expect("failed to load paths");
141        let effects_root = paths.resolve_path("$SYSTEM/effects");
142
143        // All JSON files in this directory should parse as valid effects
144        let json_files = std::fs::read_dir(&effects_root)
145            .expect("effects directory not found")
146            .filter(|res| {
147                res.as_ref()
148                    .map(|entry| {
149                        entry.path().extension().and_then(std::ffi::OsStr::to_str) == Some("json")
150                    })
151                    .unwrap_or(false)
152            })
153            .count();
154
155        let parsed = EffectDefinition::read_dir(&effects_root)
156            .await
157            .expect("read_dir failed");
158
159        eprintln!("{:#?}", parsed);
160        assert_eq!(parsed.len(), json_files);
161    }
162}