mc-snap 0.2.2

Declarative Minecraft server management. Pin Minecraft, loader, mods, Java, and configs in one YAML file; resolve, install, run, and ship a reproducible bundle.
Documentation
use serde::{Deserialize, Serialize};
use std::path::Path;

#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct Snap {
    pub schema: u32,
    #[serde(default)]
    pub eula: bool,
    pub server: Server,
    #[serde(default)]
    pub runtime: Runtime,
    #[serde(default)]
    pub mods: Vec<ModEntry>,
    #[serde(default)]
    pub config: ConfigSection,
}

#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct Server {
    pub name: String,
    #[serde(default)]
    pub description: Option<String>,
    pub minecraft: String,
    pub loader: Loader,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub location: Option<String>,
}

#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct Loader {
    #[serde(rename = "type")]
    pub kind: String,
    #[serde(default)]
    pub version: Option<String>,
    #[serde(default)]
    pub installer: Option<String>,
}

#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
pub struct Runtime {
    #[serde(default)]
    pub java: Option<u32>,
    #[serde(default)]
    pub memory: Option<String>,
    #[serde(default)]
    pub flags: Vec<String>,
}

#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(untagged)]
pub enum ModEntry {
    Registry {
        id: String,
        provider: String,
        #[serde(default = "default_version")]
        version: String,
    },
    Url {
        url: String,
        provider: String,
        sha256: String,
        #[serde(default)]
        filename: Option<String>,
    },
}

fn default_version() -> String {
    "latest".to_string()
}

fn validate_location(loc: &str) -> anyhow::Result<()> {
    let trimmed = loc.trim();
    if trimmed.is_empty() || trimmed == "." {
        return Ok(());
    }
    let p = Path::new(trimmed);
    if p.is_absolute() {
        anyhow::bail!("server.location must be a relative path, got {trimmed}");
    }
    for comp in p.components() {
        use std::path::Component;
        match comp {
            Component::ParentDir => {
                anyhow::bail!("server.location must not contain `..`")
            }
            Component::Prefix(_) | Component::RootDir => {
                anyhow::bail!("server.location must be a relative path")
            }
            _ => {}
        }
    }
    Ok(())
}

#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
pub struct ConfigSection {
    #[serde(rename = "server.properties", default)]
    pub server_properties: serde_yml::Mapping,
    #[serde(default)]
    pub files: Vec<FileRef>,
}

#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct FileRef {
    pub src: String,
    pub dst: String,
}

impl Snap {
    pub fn from_str(s: &str) -> anyhow::Result<Self> {
        let snap: Snap = serde_yml::from_str(s)?;
        snap.validate()?;
        Ok(snap)
    }

    pub fn from_path(p: &Path) -> anyhow::Result<Self> {
        let s = std::fs::read_to_string(p)?;
        Self::from_str(&s)
    }

    pub fn validate(&self) -> anyhow::Result<()> {
        if self.schema != 1 {
            anyhow::bail!("unsupported schema version {}; expected 1", self.schema);
        }
        if self.server.name.trim().is_empty() {
            anyhow::bail!("server.name must not be empty");
        }
        if self.server.minecraft.trim().is_empty() {
            anyhow::bail!("server.minecraft must not be empty");
        }
        for entry in &self.mods {
            if let ModEntry::Url { sha256, .. } = entry {
                if sha256.len() != 64 || !sha256.chars().all(|c| c.is_ascii_hexdigit()) {
                    anyhow::bail!("url mod entries require a 64-char hex sha256");
                }
            }
        }
        if let Some(loc) = &self.server.location {
            validate_location(loc)?;
        }
        Ok(())
    }
}

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

    const SAMPLE: &str = r#"
schema: 1
eula: true
server:
  name: grimwald
  description: the grimwald smp
  minecraft: 26.1.2
  loader:
    type: fabric
    version: 0.16.9
runtime:
  java: 26
  memory: 4G
  flags:
    - -XX:+UseG1GC
mods:
  - id: fabric-api
    provider: modrinth
    version: "0.140.0+26.1.2"
  - url: https://example.com/mymod.jar
    provider: url
    sha256: 0000000000000000000000000000000000000000000000000000000000000000
config:
  server.properties:
    motd: hi
    max-players: 20
"#;

    #[test]
    fn parses_sample() {
        let snap = Snap::from_str(SAMPLE).unwrap();
        assert_eq!(snap.server.name, "grimwald");
        assert_eq!(snap.server.loader.kind, "fabric");
        assert_eq!(snap.mods.len(), 2);
        assert_eq!(snap.runtime.java, Some(26));
    }

    #[test]
    fn rejects_bad_sha() {
        let bad = r#"
schema: 1
server:
  name: x
  minecraft: 26.1.2
  loader: { type: vanilla }
mods:
  - url: https://example.com/x.jar
    provider: url
    sha256: deadbeef
"#;
        assert!(Snap::from_str(bad).is_err());
    }

    #[test]
    fn rejects_wrong_schema() {
        let bad = "schema: 2\nserver:\n  name: x\n  minecraft: 26.1.2\n  loader: { type: vanilla }\n";
        assert!(Snap::from_str(bad).is_err());
    }

    #[test]
    fn accepts_relative_location() {
        let yml = "schema: 1\nserver:\n  name: x\n  minecraft: 26.1.2\n  location: server\n  loader: { type: vanilla }\n";
        let snap = Snap::from_str(yml).unwrap();
        assert_eq!(snap.server.location.as_deref(), Some("server"));
    }

    #[test]
    fn rejects_absolute_location() {
        let yml = "schema: 1\nserver:\n  name: x\n  minecraft: 26.1.2\n  location: /etc\n  loader: { type: vanilla }\n";
        assert!(Snap::from_str(yml).is_err());
    }

    #[test]
    fn rejects_parent_traversal_location() {
        let yml = "schema: 1\nserver:\n  name: x\n  minecraft: 26.1.2\n  location: ../escape\n  loader: { type: vanilla }\n";
        assert!(Snap::from_str(yml).is_err());
    }

    #[test]
    fn missing_location_defaults_to_none() {
        let snap = Snap::from_str(SAMPLE).unwrap();
        assert!(snap.server.location.is_none());
    }
}