aion-server 0.27.1

Aion workflow server library: HTTP, gRPC, WebSocket, and worker endpoints. Run it with the `aion` binary from the aion-cli crate.
Documentation
use std::collections::{BTreeMap, BTreeSet};
use std::ffi::OsStr;
use std::io;
use std::path::{Path, PathBuf};

use serde::{Deserialize, Serialize};

use crate::filesystem::ConfinedDir;

#[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq)]
pub struct LayoutPosition {
    pub x: f64,
    pub y: f64,
}

#[derive(Debug, Default, Deserialize, Serialize, PartialEq)]
pub struct LayoutRecord {
    pub positions: BTreeMap<String, LayoutPosition>,
}

#[derive(Debug, thiserror::Error)]
pub enum LayoutError {
    #[error("invalid AWL layout path: {0}")]
    InvalidPath(String),
    #[error("AWL document was not found: {0}")]
    DocumentNotFound(String),
    #[error("AWL layout I/O failed: {0}")]
    Io(#[from] io::Error),
}

pub async fn read(
    root: &Path,
    requested: &str,
    subject: &str,
) -> Result<LayoutRecord, LayoutError> {
    let document = validate_document_path(requested)?;
    let root = root.to_owned();
    let requested = requested.to_owned();
    let sidecar = sidecar_path(&document, subject);
    blocking("layout read", move || {
        let workspace = ConfinedDir::open(&root).map_err(LayoutError::Io)?;
        let source = read_document(&workspace, &document, &requested)?;
        let mut record = match workspace.read(&sidecar) {
            Ok(bytes) => serde_json::from_slice(&bytes).map_err(invalid_json)?,
            Err(error) if error.kind() == io::ErrorKind::NotFound => {
                return Ok(LayoutRecord::default());
            }
            Err(error) => return Err(LayoutError::Io(error)),
        };
        let Some(names) = parsed_step_names(&source) else {
            return Ok(record);
        };
        let previous = record.positions.len();
        record.positions.retain(|name, _| names.contains(name));
        if record.positions.len() != previous {
            let bytes = serde_json::to_vec_pretty(&record).map_err(invalid_json)?;
            workspace.atomic_write(&sidecar, &bytes)?;
        }
        Ok(record)
    })
    .await
}

pub async fn write(
    root: &Path,
    requested: &str,
    subject: &str,
    record: LayoutRecord,
) -> Result<LayoutRecord, LayoutError> {
    if record
        .positions
        .values()
        .any(|position| !position.x.is_finite() || !position.y.is_finite())
    {
        return Err(LayoutError::InvalidPath(
            "layout positions must be finite numbers".to_owned(),
        ));
    }
    let document = validate_document_path(requested)?;
    let root = root.to_owned();
    let requested = requested.to_owned();
    let sidecar = sidecar_path(&document, subject);
    blocking("layout write", move || {
        let workspace = ConfinedDir::open(&root).map_err(LayoutError::Io)?;
        let source = read_document(&workspace, &document, &requested)?;
        let names = parsed_step_names(&source).ok_or_else(|| {
            LayoutError::InvalidPath("cannot store layout for an invalid AWL document".to_owned())
        })?;
        if record.positions.keys().any(|name| !names.contains(name)) {
            return Err(LayoutError::InvalidPath(
                "layout contains a step name not declared by the document".to_owned(),
            ));
        }
        let bytes = serde_json::to_vec_pretty(&record).map_err(invalid_json)?;
        workspace.atomic_write(&sidecar, &bytes)?;
        Ok(record)
    })
    .await
}

fn validate_document_path(requested: &str) -> Result<PathBuf, LayoutError> {
    super::documents::document_path(requested).map_err(|error| {
        LayoutError::InvalidPath(error.to_string().replace("invalid AWL document path: ", ""))
    })
}

fn read_document(
    workspace: &ConfinedDir,
    relative: &Path,
    requested: &str,
) -> Result<String, LayoutError> {
    workspace.read_to_string(relative).map_err(|error| {
        if error.kind() == io::ErrorKind::NotFound {
            LayoutError::DocumentNotFound(requested.to_owned())
        } else if matches!(
            error.kind(),
            io::ErrorKind::InvalidInput | io::ErrorKind::NotADirectory
        ) {
            LayoutError::InvalidPath(format!("document path contains a link: {error}"))
        } else {
            LayoutError::Io(error)
        }
    })
}

fn parsed_step_names(source: &str) -> Option<BTreeSet<String>> {
    aion_awl::parse(source)
        .ok()
        .map(|document| document.steps.into_iter().map(|step| step.name).collect())
}

fn sidecar_path(document: &Path, subject: &str) -> PathBuf {
    const HEX: &[u8; 16] = b"0123456789abcdef";
    let mut user = String::with_capacity(subject.len() * 2);
    for byte in subject.as_bytes() {
        user.push(char::from(HEX[usize::from(byte >> 4)]));
        user.push(char::from(HEX[usize::from(byte & 0x0f)]));
    }
    let mut path = Path::new(".aion").join("layout").join(user).join(document);
    let file_name = path
        .file_name()
        .unwrap_or(OsStr::new("document.awl"))
        .to_string_lossy();
    path.set_file_name(format!("{file_name}.json"));
    path
}

async fn blocking<T: Send + 'static>(
    operation: &'static str,
    work: impl FnOnce() -> Result<T, LayoutError> + Send + 'static,
) -> Result<T, LayoutError> {
    tokio::task::spawn_blocking(work)
        .await
        .map_err(|error| io::Error::other(format!("{operation} task failed: {error}")))?
}

fn invalid_json(error: impl std::fmt::Display) -> LayoutError {
    LayoutError::Io(io::Error::new(
        io::ErrorKind::InvalidData,
        error.to_string(),
    ))
}

#[cfg(test)]
mod tests {
    use super::*;

    const SOURCE: &str = "//! Layout test\nworkflow layout_test\n  outcome done: type String, route success\n\n/// First step\nstep first\n  route done(value: \"ok\")\n";

    #[tokio::test]
    async fn layout_is_per_user_atomic_and_garbage_collects_orphans()
    -> Result<(), Box<dyn std::error::Error>> {
        let workspace = crate::test_support::private_tempdir()?;
        super::super::documents::write(
            workspace.path(),
            "flow.awl",
            super::super::documents::PutDocumentRequest {
                source: SOURCE.to_owned(),
            },
        )
        .await?;
        let alice = LayoutRecord {
            positions: BTreeMap::from([("first".to_owned(), LayoutPosition { x: 10.0, y: 20.0 })]),
        };
        write(workspace.path(), "flow.awl", "alice", alice).await?;
        let stored_x = read(workspace.path(), "flow.awl", "alice").await?.positions["first"].x;
        assert!((stored_x - 10.0).abs() < f64::EPSILON);
        assert!(
            read(workspace.path(), "flow.awl", "bob")
                .await?
                .positions
                .is_empty()
        );
        Ok(())
    }

    #[tokio::test]
    async fn invalid_positions_and_unknown_steps_are_refused()
    -> Result<(), Box<dyn std::error::Error>> {
        let workspace = crate::test_support::private_tempdir()?;
        super::super::documents::write(
            workspace.path(),
            "flow.awl",
            super::super::documents::PutDocumentRequest {
                source: SOURCE.to_owned(),
            },
        )
        .await?;
        let invalid = LayoutRecord {
            positions: BTreeMap::from([(
                "first".to_owned(),
                LayoutPosition {
                    x: f64::NAN,
                    y: 0.0,
                },
            )]),
        };
        assert!(matches!(
            write(workspace.path(), "flow.awl", "alice", invalid).await,
            Err(LayoutError::InvalidPath(_))
        ));
        let unknown = LayoutRecord {
            positions: BTreeMap::from([("missing".to_owned(), LayoutPosition { x: 0.0, y: 0.0 })]),
        };
        assert!(matches!(
            write(workspace.path(), "flow.awl", "alice", unknown).await,
            Err(LayoutError::InvalidPath(_))
        ));
        Ok(())
    }

    #[cfg(unix)]
    #[tokio::test]
    async fn layout_parent_and_predictable_temp_links_cannot_escape()
    -> Result<(), Box<dyn std::error::Error>> {
        use std::os::unix::fs::symlink;

        let sandbox = crate::test_support::private_tempdir()?;
        let workspace = sandbox.path().join("workspace");
        let outside = sandbox.path().join("outside");
        std::fs::create_dir(&workspace)?;
        crate::test_support::make_private(&workspace)?;
        std::fs::create_dir(&outside)?;
        super::super::documents::write(
            &workspace,
            "flow.awl",
            super::super::documents::PutDocumentRequest {
                source: SOURCE.to_owned(),
            },
        )
        .await?;
        symlink(&outside, workspace.join(".aion"))?;
        let record = LayoutRecord {
            positions: BTreeMap::from([("first".to_owned(), LayoutPosition { x: 1.0, y: 2.0 })]),
        };
        assert!(
            write(&workspace, "flow.awl", "alice", record)
                .await
                .is_err()
        );
        assert!(std::fs::read_dir(&outside)?.next().is_none());

        std::fs::remove_file(workspace.join(".aion"))?;
        std::fs::create_dir(workspace.join(".aion"))?;
        let victim = outside.join("victim");
        symlink(&victim, workspace.join(".aion/.aion-layout.tmp"))?;
        let record = LayoutRecord {
            positions: BTreeMap::from([("first".to_owned(), LayoutPosition { x: 1.0, y: 2.0 })]),
        };
        write(&workspace, "flow.awl", "alice", record).await?;
        assert!(!victim.exists());
        Ok(())
    }
}