Skip to main content

cobble/commands/
validate.rs

1use crate::pack_format::SUPPORTED_MINECRAFT_VERSION;
2use crate::transpiler::SourceMap;
3use crate::validator::CommandValidator;
4use crate::validator::ValidationReport;
5use sha1::{Digest, Sha1};
6use std::collections::{HashMap, HashSet};
7use std::fs;
8use std::io::Read;
9use std::path::{Component, Path, PathBuf};
10use std::process::Command;
11use walkdir::WalkDir;
12
13const VERSION_MANIFEST_URLS: &[&str] = &[
14    "https://piston-meta.mojang.com/mc/game/version_manifest_v2.json",
15    "https://launchermeta.mojang.com/mc/game/version_manifest_v2.json",
16    "https://piston-meta.mojang.com/mc/game/version_manifest.json",
17    "https://launchermeta.mojang.com/mc/game/version_manifest.json",
18];
19
20const SUPPORTED_SERVER_JAR_URL: &str =
21    "https://piston-data.mojang.com/v1/objects/97ccd4c0ed3f81bbb7bfacddd1090b0c56f9bc51/server.jar";
22const SUPPORTED_SERVER_JAR_SHA1: &str = "97ccd4c0ed3f81bbb7bfacddd1090b0c56f9bc51";
23pub const SUPPORTED_COMMANDS_JSON_SHA1: &str = "18bb0eb6768838b2237821418aa5832d1c837d45";
24
25pub struct ValidateOptions {
26    pub input: PathBuf,
27    pub commands_json: PathBuf,
28}
29
30pub fn validate(options: ValidateOptions) -> Result<(), String> {
31    let report = run_validation(&options.input, &options.commands_json)?;
32    print_validation_report(&report, &options.commands_json, &options.input);
33
34    if report.errors.is_empty() && report.source_map_errors.is_empty() {
35        println!("All commands valid");
36        Ok(())
37    } else {
38        Err(format!(
39            "{} validation error(s) found",
40            report.errors.len() + report.source_map_errors.len()
41        ))
42    }
43}
44
45pub fn run_validation(input: &Path, commands_json: &Path) -> Result<ValidationReport, String> {
46    ensure_commands_json(commands_json)?;
47
48    let validator = CommandValidator::from_file(commands_json)?;
49    let mut report = validator.validate_datapack(input);
50    report.source_map_errors = validate_source_map(input);
51    Ok(report)
52}
53
54fn ensure_commands_json(commands_json: &Path) -> Result<(), String> {
55    if commands_json.exists() {
56        return verify_default_commands_json_fingerprint(commands_json);
57    }
58
59    if !is_auto_download_commands_json_path(commands_json) {
60        return Err(missing_command_tree_error(commands_json));
61    }
62
63    if std::env::var("COBBLE_COMMANDS_JSON_AUTO_DOWNLOAD").as_deref() == Ok("0") {
64        return Err(format!(
65            "{}\nAutomatic download is disabled by COBBLE_COMMANDS_JSON_AUTO_DOWNLOAD=0.",
66            missing_command_tree_error(commands_json)
67        ));
68    }
69
70    generate_commands_json(commands_json)?;
71    verify_default_commands_json_fingerprint(commands_json)
72}
73
74fn verify_default_commands_json_fingerprint(commands_json: &Path) -> Result<(), String> {
75    if !is_auto_download_commands_json_path(commands_json) {
76        return Ok(());
77    }
78
79    verify_commands_json_sha1(commands_json, SUPPORTED_COMMANDS_JSON_SHA1).map_err(|error| {
80        format!(
81            "{}\n\
82            The default command tree must match Minecraft Java Edition {}. Remove {} and rerun validation to regenerate it, or pass --commands-json with a deliberate custom tree.",
83            error,
84            SUPPORTED_MINECRAFT_VERSION,
85            commands_json.display()
86        )
87    })
88}
89
90fn verify_commands_json_sha1(commands_json: &Path, expected_sha1: &str) -> Result<(), String> {
91    let actual_sha1 = sha1_file(commands_json)?;
92    if actual_sha1.eq_ignore_ascii_case(expected_sha1) {
93        return Ok(());
94    }
95
96    Err(format!(
97        "Command tree SHA-1 mismatch for {}: expected {}, got {}",
98        commands_json.display(),
99        expected_sha1,
100        actual_sha1
101    ))
102}
103
104fn missing_command_tree_error(commands_json: &Path) -> String {
105    format!(
106        "Command tree not found: {}\n\
107        Cobble can auto-generate the default data/commands.json path during validation.\n\
108        For custom paths, generate data/commands.json with scripts/setup_commands_json.sh {} and copy it to the requested path.\n\
109        Default path: data/commands.json",
110        commands_json.display(),
111        SUPPORTED_MINECRAFT_VERSION
112    )
113}
114
115fn is_auto_download_commands_json_path(commands_json: &Path) -> bool {
116    commands_json == Path::new("data/commands.json")
117}
118
119fn generate_commands_json(commands_json: &Path) -> Result<(), String> {
120    let output_dir = commands_json.parent().unwrap_or_else(|| Path::new("."));
121    fs::create_dir_all(output_dir).map_err(|e| {
122        format!(
123            "Failed to create commands.json directory {}: {}",
124            output_dir.display(),
125            e
126        )
127    })?;
128
129    let stamp = std::time::SystemTime::now()
130        .duration_since(std::time::UNIX_EPOCH)
131        .map_err(|e| {
132            format!(
133                "System clock error while creating command tree cache: {}",
134                e
135            )
136        })?
137        .as_nanos();
138    let work_dir = output_dir.join(format!(".commands-json-{}-{}", std::process::id(), stamp));
139    fs::create_dir_all(&work_dir).map_err(|e| {
140        format!(
141            "Failed to create temporary command tree directory {}: {}",
142            work_dir.display(),
143            e
144        )
145    })?;
146
147    println!(
148        "Command tree not found at {}; generating Minecraft {} commands.json...",
149        commands_json.display(),
150        SUPPORTED_MINECRAFT_VERSION
151    );
152
153    let result = generate_commands_json_in(&work_dir, commands_json);
154    let cleanup_result = fs::remove_dir_all(&work_dir);
155    if let Err(error) = cleanup_result {
156        eprintln!(
157            "Warning: failed to remove temporary command tree directory {}: {}",
158            work_dir.display(),
159            error
160        );
161    }
162    result
163}
164
165fn generate_commands_json_in(work_dir: &Path, commands_json: &Path) -> Result<(), String> {
166    if let Ok(commands_url) = std::env::var("COBBLE_COMMANDS_JSON_URL") {
167        download_ready_made_commands_json(&commands_url, commands_json)?;
168        return Ok(());
169    }
170
171    let server_jar = work_dir.join("server.jar");
172    let expected_sha1 = prepare_server_jar(&server_jar)?;
173    verify_sha1(&server_jar, &expected_sha1)?;
174    let server_jar = server_jar.canonicalize().map_err(|e| {
175        format!(
176            "Failed to resolve Minecraft server jar path {}: {}",
177            server_jar.display(),
178            e
179        )
180    })?;
181
182    run_command(
183        Command::new("java")
184            .arg("-DbundlerMainClass=net.minecraft.data.Main")
185            .arg("-jar")
186            .arg(&server_jar)
187            .arg("--reports")
188            .current_dir(work_dir),
189        "generate Minecraft command reports",
190    )?;
191
192    let generated_commands = find_generated_commands_json(work_dir).ok_or_else(|| {
193        format!(
194            "Minecraft server reports did not generate commands.json under {}",
195            work_dir.display()
196        )
197    })?;
198
199    let partial = commands_json.with_file_name("commands.json.part");
200    let _ = fs::remove_file(&partial);
201    fs::copy(&generated_commands, &partial).map_err(|e| {
202        format!(
203            "Failed to copy generated commands.json from {} to {}: {}",
204            generated_commands.display(),
205            partial.display(),
206            e
207        )
208    })?;
209    fs::rename(&partial, commands_json).map_err(|e| {
210        format!(
211            "Failed to move generated commands.json to {}: {}",
212            commands_json.display(),
213            e
214        )
215    })?;
216
217    println!("Generated commands.json at {}", commands_json.display());
218    Ok(())
219}
220
221fn download_ready_made_commands_json(url: &str, commands_json: &Path) -> Result<(), String> {
222    println!("Downloading commands.json from COBBLE_COMMANDS_JSON_URL...");
223    let partial = commands_json.with_file_name("commands.json.part");
224    let _ = fs::remove_file(&partial);
225    run_command(
226        Command::new("curl")
227            .args(["-fsSL", "--retry", "3", "-o"])
228            .arg(&partial)
229            .arg(url),
230        "download commands.json",
231    )?;
232    fs::rename(&partial, commands_json).map_err(|e| {
233        format!(
234            "Failed to move downloaded commands.json to {}: {}",
235            commands_json.display(),
236            e
237        )
238    })?;
239    println!("Downloaded commands.json at {}", commands_json.display());
240    Ok(())
241}
242
243fn prepare_server_jar(server_jar: &Path) -> Result<String, String> {
244    let override_sha1 = std::env::var("COBBLE_MINECRAFT_SERVER_SHA1").ok();
245
246    if let Ok(local_jar) = std::env::var("COBBLE_MINECRAFT_SERVER_JAR") {
247        let local_jar = PathBuf::from(local_jar);
248        if !local_jar.exists() {
249            return Err(format!(
250                "COBBLE_MINECRAFT_SERVER_JAR points to a missing file: {}",
251                local_jar.display()
252            ));
253        }
254        fs::copy(&local_jar, server_jar).map_err(|e| {
255            format!(
256                "Failed to copy local Minecraft server jar from {} to {}: {}",
257                local_jar.display(),
258                server_jar.display(),
259                e
260            )
261        })?;
262        return Ok(override_sha1.unwrap_or_else(|| SUPPORTED_SERVER_JAR_SHA1.to_string()));
263    }
264
265    let (server_url, expected_sha1) = if let Ok(url) = std::env::var("COBBLE_MINECRAFT_SERVER_URL")
266    {
267        (
268            url,
269            override_sha1.unwrap_or_else(|| SUPPORTED_SERVER_JAR_SHA1.to_string()),
270        )
271    } else {
272        resolve_server_jar_url()?
273    };
274
275    run_command(
276        Command::new("curl")
277            .args(["-fsSL", "--retry", "3", "-o"])
278            .arg(server_jar)
279            .arg(&server_url),
280        "download Minecraft server.jar",
281    )?;
282    Ok(expected_sha1)
283}
284
285fn resolve_server_jar_url() -> Result<(String, String), String> {
286    let mut errors = Vec::new();
287    for manifest_url in VERSION_MANIFEST_URLS {
288        let manifest = match curl_json(manifest_url) {
289            Ok(manifest) => manifest,
290            Err(error) => {
291                errors.push(format!("{}: {}", manifest_url, error));
292                continue;
293            }
294        };
295
296        let Some(version_url) = manifest
297            .get("versions")
298            .and_then(|value| value.as_array())
299            .and_then(|versions| {
300                versions.iter().find_map(|version| {
301                    if version.get("id").and_then(|value| value.as_str())
302                        == Some(SUPPORTED_MINECRAFT_VERSION)
303                    {
304                        version.get("url").and_then(|value| value.as_str())
305                    } else {
306                        None
307                    }
308                })
309            })
310        else {
311            errors.push(format!(
312                "{}: Minecraft version {} was not found",
313                manifest_url, SUPPORTED_MINECRAFT_VERSION
314            ));
315            continue;
316        };
317
318        let version_info = match curl_json(version_url) {
319            Ok(version_info) => version_info,
320            Err(error) => {
321                errors.push(format!("{}: {}", version_url, error));
322                continue;
323            }
324        };
325
326        let server_url = version_info
327            .pointer("/downloads/server/url")
328            .and_then(|value| value.as_str());
329        let server_sha1 = version_info
330            .pointer("/downloads/server/sha1")
331            .and_then(|value| value.as_str());
332
333        if let (Some(server_url), Some(server_sha1)) = (server_url, server_sha1) {
334            return Ok((server_url.to_string(), server_sha1.to_string()));
335        }
336
337        errors.push(format!(
338            "{}: metadata does not include a server download URL and SHA-1",
339            version_url
340        ));
341    }
342
343    eprintln!(
344        "Warning: failed to resolve Minecraft server jar from manifests; using pinned {} direct server.jar URL.",
345        SUPPORTED_MINECRAFT_VERSION
346    );
347    for error in &errors {
348        eprintln!("  - {}", error);
349    }
350    Ok((
351        SUPPORTED_SERVER_JAR_URL.to_string(),
352        SUPPORTED_SERVER_JAR_SHA1.to_string(),
353    ))
354}
355
356fn verify_sha1(path: &Path, expected_sha1: &str) -> Result<(), String> {
357    let actual_sha1 = sha1_file(path)?;
358    if actual_sha1.eq_ignore_ascii_case(expected_sha1) {
359        return Ok(());
360    }
361
362    let _ = fs::remove_file(path);
363    Err(format!(
364        "Downloaded Minecraft server jar failed SHA-1 verification: expected {}, got {}",
365        expected_sha1, actual_sha1
366    ))
367}
368
369fn sha1_file(path: &Path) -> Result<String, String> {
370    let mut file = fs::File::open(path).map_err(|e| {
371        format!(
372            "Failed to open {} for SHA-1 verification: {}",
373            path.display(),
374            e
375        )
376    })?;
377    let mut hasher = Sha1::new();
378    let mut buffer = [0_u8; 8192];
379    loop {
380        let read = file.read(&mut buffer).map_err(|e| {
381            format!(
382                "Failed to read {} for SHA-1 verification: {}",
383                path.display(),
384                e
385            )
386        })?;
387        if read == 0 {
388            break;
389        }
390        hasher.update(&buffer[..read]);
391    }
392    Ok(format!("{:x}", hasher.finalize()))
393}
394
395fn curl_json(url: &str) -> Result<serde_json::Value, String> {
396    let output = Command::new("curl")
397        .args(["-fsSL", "--retry", "3", url])
398        .output()
399        .map_err(|e| format!("Failed to execute curl while fetching {}: {}", url, e))?;
400    if !output.status.success() {
401        return Err(format!(
402            "curl failed while fetching {}: {}",
403            url,
404            String::from_utf8_lossy(&output.stderr)
405        ));
406    }
407    serde_json::from_slice(&output.stdout)
408        .map_err(|e| format!("Failed to parse JSON response from {}: {}", url, e))
409}
410
411fn run_command(command: &mut Command, description: &str) -> Result<(), String> {
412    let output = command
413        .output()
414        .map_err(|e| format!("Failed to execute command to {}: {}", description, e))?;
415    if output.status.success() {
416        return Ok(());
417    }
418
419    let stderr = String::from_utf8_lossy(&output.stderr);
420    let stdout = String::from_utf8_lossy(&output.stdout);
421    Err(format!(
422        "Failed to {}.\nstdout:\n{}\nstderr:\n{}",
423        description, stdout, stderr
424    ))
425}
426
427fn find_generated_commands_json(work_dir: &Path) -> Option<PathBuf> {
428    WalkDir::new(work_dir)
429        .follow_links(false)
430        .into_iter()
431        .filter_map(|entry| entry.ok())
432        .map(|entry| entry.into_path())
433        .find(|path| {
434            path.is_file()
435                && path.file_name().and_then(|name| name.to_str()) == Some("commands.json")
436                && path
437                    .components()
438                    .any(|component| component.as_os_str() == "reports")
439        })
440}
441
442pub fn print_validation_report(
443    report: &ValidationReport,
444    commands_json: &Path,
445    datapack_dir: &Path,
446) {
447    let source_map = load_source_map(datapack_dir);
448    for (file, error) in &report.errors {
449        eprintln!(
450            "{}:{}: {}",
451            file.display(),
452            error.line_number,
453            error.message
454        );
455        eprintln!("  | {}", error.command);
456        if error.position <= error.command.chars().count() {
457            eprintln!("  | {}^", " ".repeat(error.position));
458        }
459        if let Some(entry) = source_map.get(&source_map_key(datapack_dir, file, error.line_number))
460        {
461            if let Some(source) = &entry.source {
462                eprintln!(
463                    "  = source: {}:{}:{} ({:?})",
464                    source.file.display(),
465                    source.line,
466                    source.column,
467                    entry.kind
468                );
469            }
470        }
471    }
472    for error in &report.source_map_errors {
473        eprintln!("source map: {}", error);
474    }
475
476    println!(
477        "Checked {} commands in {} files ({} macro commands checked, {} skipped) using {}",
478        report.commands_checked,
479        report.files_checked,
480        report.macro_commands_checked,
481        report.commands_skipped,
482        commands_json.display()
483    );
484}
485
486fn validate_source_map(datapack_dir: &Path) -> Vec<String> {
487    let path = datapack_dir.join(".cobble").join("source_map.json");
488    let Ok(content) = std::fs::read_to_string(&path) else {
489        return Vec::new();
490    };
491    let Ok(source_map) = serde_json::from_str::<SourceMap>(&content) else {
492        return vec![format!("failed to parse {}", path.display())];
493    };
494
495    let mut errors = Vec::new();
496    let mut mapped_lines = HashSet::new();
497
498    for entry in source_map.entries {
499        let Some(generated_path) = clean_source_map_generated_path(&entry.generated_path) else {
500            errors.push(format!(
501                "{}:{} has invalid generated_path outside the data pack",
502                entry.generated_path, entry.generated_line
503            ));
504            continue;
505        };
506        let generated_path_key = generated_path.to_string_lossy().replace('\\', "/");
507        let generated_file = datapack_dir.join(&generated_path);
508        mapped_lines.insert((generated_path_key.clone(), entry.generated_line));
509        if !is_regular_file_no_symlink(&generated_file) {
510            errors.push(format!(
511                "{}:{} maps to missing or non-regular file",
512                generated_path_key, entry.generated_line
513            ));
514            continue;
515        }
516        let Ok(file_content) = std::fs::read_to_string(&generated_file) else {
517            errors.push(format!(
518                "{}:{} maps to missing file",
519                generated_path_key, entry.generated_line
520            ));
521            continue;
522        };
523        let actual = file_content
524            .lines()
525            .nth(entry.generated_line.saturating_sub(1));
526        match actual {
527            Some(actual) if actual == entry.command => {}
528            Some(actual) => errors.push(format!(
529                "{}:{} command mismatch: map='{}' file='{}'",
530                generated_path_key, entry.generated_line, entry.command, actual
531            )),
532            None => errors.push(format!(
533                "{}:{} maps past end of file",
534                generated_path_key, entry.generated_line
535            )),
536        }
537    }
538
539    for entry in WalkDir::new(datapack_dir)
540        .follow_links(false)
541        .into_iter()
542        .filter_map(|entry| entry.ok())
543    {
544        let path = entry.path();
545        if !entry.file_type().is_file()
546            || path.extension().and_then(|ext| ext.to_str()) != Some("mcfunction")
547        {
548            continue;
549        }
550        let relative = path
551            .strip_prefix(datapack_dir)
552            .unwrap_or(path)
553            .to_string_lossy()
554            .replace('\\', "/");
555        if let Ok(content) = std::fs::read_to_string(path) {
556            for (index, line) in content.lines().enumerate() {
557                if line.trim().is_empty() || line.trim_start().starts_with('#') {
558                    continue;
559                }
560                let generated_line = index + 1;
561                if !mapped_lines.contains(&(relative.clone(), generated_line)) {
562                    errors.push(format!(
563                        "{}:{} has no source map entry",
564                        relative, generated_line
565                    ));
566                }
567            }
568        }
569    }
570
571    errors
572}
573
574fn is_regular_file_no_symlink(path: &Path) -> bool {
575    std::fs::symlink_metadata(path)
576        .map(|metadata| metadata.file_type().is_file())
577        .unwrap_or(false)
578}
579
580fn clean_source_map_generated_path(generated_path: &str) -> Option<PathBuf> {
581    let path = Path::new(generated_path);
582    if path.is_absolute() {
583        return None;
584    }
585
586    let mut clean = PathBuf::new();
587    for component in path.components() {
588        match component {
589            Component::Normal(part) => clean.push(part),
590            _ => return None,
591        }
592    }
593
594    if clean.as_os_str().is_empty() {
595        None
596    } else {
597        Some(clean)
598    }
599}
600
601fn load_source_map(
602    datapack_dir: &Path,
603) -> HashMap<(String, usize), crate::transpiler::SourceMapEntry> {
604    let path = datapack_dir.join(".cobble").join("source_map.json");
605    let Ok(content) = std::fs::read_to_string(path) else {
606        return HashMap::new();
607    };
608    let Ok(source_map) = serde_json::from_str::<SourceMap>(&content) else {
609        return HashMap::new();
610    };
611
612    source_map
613        .entries
614        .into_iter()
615        .map(|entry| ((entry.generated_path.clone(), entry.generated_line), entry))
616        .collect()
617}
618
619fn source_map_key(datapack_dir: &Path, generated_file: &Path, line: usize) -> (String, usize) {
620    let relative = generated_file
621        .strip_prefix(datapack_dir)
622        .unwrap_or(generated_file)
623        .to_string_lossy()
624        .replace('\\', "/");
625    (relative, line)
626}
627
628#[cfg(test)]
629mod tests {
630    use super::*;
631
632    #[test]
633    fn auto_download_only_applies_to_default_commands_json_paths() {
634        assert!(is_auto_download_commands_json_path(Path::new(
635            "data/commands.json"
636        )));
637        assert!(!is_auto_download_commands_json_path(Path::new(
638            "../../data/commands.json"
639        )));
640        assert!(!is_auto_download_commands_json_path(Path::new(
641            "/tmp/project/data/commands.json"
642        )));
643
644        assert!(!is_auto_download_commands_json_path(Path::new(
645            "commands.json"
646        )));
647        assert!(!is_auto_download_commands_json_path(Path::new(
648            "data/missing_commands.json"
649        )));
650        assert!(!is_auto_download_commands_json_path(Path::new(
651            "custom/commands.json"
652        )));
653    }
654
655    #[test]
656    fn missing_custom_commands_json_path_reports_manual_setup() {
657        let temp_dir = tempfile::TempDir::new().unwrap();
658        let input_dir = temp_dir.path().join("pack");
659        let commands_json = temp_dir.path().join("missing_commands.json");
660        fs::create_dir_all(&input_dir).unwrap();
661
662        let error = run_validation(&input_dir, &commands_json).unwrap_err();
663
664        assert!(error.contains("Command tree not found"));
665        assert!(error.contains("scripts/setup_commands_json.sh"));
666        assert!(!commands_json.exists());
667    }
668
669    #[test]
670    fn command_tree_fingerprint_rejects_wrong_sha1() {
671        let temp_dir = tempfile::TempDir::new().unwrap();
672        let commands_json = temp_dir.path().join("commands.json");
673        fs::write(&commands_json, "{}").unwrap();
674
675        let error =
676            verify_commands_json_sha1(&commands_json, SUPPORTED_COMMANDS_JSON_SHA1).unwrap_err();
677
678        assert!(error.contains("Command tree SHA-1 mismatch"));
679    }
680
681    #[test]
682    fn run_validation_accepts_custom_command_tree_without_supported_fingerprint() {
683        let temp_dir = tempfile::TempDir::new().unwrap();
684        let input_dir = temp_dir.path().join("pack");
685        let commands_json = temp_dir.path().join("custom_commands.json");
686        fs::create_dir_all(&input_dir).unwrap();
687        fs::write(&commands_json, r#"{"type":"root","children":{}}"#).unwrap();
688
689        let report = run_validation(&input_dir, &commands_json).unwrap();
690
691        assert_eq!(report.files_checked, 0);
692        assert!(report.errors.is_empty());
693        assert!(report.source_map_errors.is_empty());
694    }
695
696    #[cfg(unix)]
697    #[test]
698    fn download_ready_made_commands_json_accepts_local_fixture_url() {
699        if Command::new("curl").arg("--version").output().is_err() {
700            eprintln!("Skipping commands.json download fixture test: curl not available");
701            return;
702        }
703
704        let temp_dir = tempfile::TempDir::new().unwrap();
705        let fixture = temp_dir.path().join("fixture_commands.json");
706        let commands_json = temp_dir.path().join("commands.json");
707        let fixture_content = r#"{"type":"root","children":{}}"#;
708        fs::write(&fixture, fixture_content).unwrap();
709
710        download_ready_made_commands_json(&format!("file://{}", fixture.display()), &commands_json)
711            .unwrap();
712
713        assert_eq!(fs::read_to_string(commands_json).unwrap(), fixture_content);
714        assert!(!temp_dir.path().join("commands.json.part").exists());
715    }
716}