procyon 0.1.2

Terminal development harness for Stellar and Soroban smart contracts, driven by a language model
use std::path::{Component, Path, PathBuf};

fn normalize_lexically(path: &Path) -> PathBuf {
    let mut out = PathBuf::new();
    for component in path.components() {
        match component {
            Component::CurDir => {}
            Component::ParentDir => {
                out.pop();
            }
            other => out.push(other.as_os_str()),
        }
    }
    out
}

// Tool arguments come from the model, so any path is untrusted. Everything is resolved against
// the directory procyon was launched from and rejected if it lands outside.
pub fn resolve_in_workspace(path: &str) -> Result<PathBuf, String> {
    let root = std::env::current_dir()
        .and_then(|d| d.canonicalize())
        .map_err(|e| format!("Failed to resolve workspace root: {}", e))?;
    resolve_under(&root, path, "workspace")
}

// The same guard against a second root: the Stellar docs live outside the workspace and are read
// through their own confinement rather than by loosening this one.
pub fn resolve_under(root: &Path, path: &str, label: &str) -> Result<PathBuf, String> {
    let root = root
        .canonicalize()
        .map_err(|e| format!("Failed to resolve {} root {}: {}", label, root.display(), e))?;

    let requested = Path::new(path);
    let joined = if requested.is_absolute() {
        requested.to_path_buf()
    } else {
        root.join(requested)
    };

    let candidate = normalize_lexically(&joined);
    if !candidate.starts_with(&root) {
        return Err(format!(
            "Path is outside the {} {}: {}",
            label,
            root.display(),
            path
        ));
    }

    // The lexical check alone is not enough: a target that does not exist yet can still sit
    // behind a symlinked parent pointing outside the root, so the deepest component that does
    // exist is resolved for real and re-checked.
    let anchor = nearest_existing_ancestor(&candidate)
        .ok_or_else(|| format!("Path is outside the {}: {}", label, path))?;
    let real_anchor = anchor
        .canonicalize()
        .map_err(|e| format!("Failed to resolve {}: {}", anchor.display(), e))?;
    if !real_anchor.starts_with(&root) {
        return Err(format!(
            "Path escapes the {} via a symlink: {}",
            label, path
        ));
    }

    Ok(candidate)
}

fn nearest_existing_ancestor(path: &Path) -> Option<&Path> {
    let mut current = path;
    loop {
        if current.exists() {
            return Some(current);
        }
        current = current.parent()?;
    }
}

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

    fn root() -> PathBuf {
        std::env::current_dir().unwrap().canonicalize().unwrap()
    }

    #[test]
    fn accepts_relative_path_inside_workspace() {
        let resolved = resolve_in_workspace("src/main.rs").unwrap();
        assert_eq!(resolved, root().join("src/main.rs"));
    }

    #[test]
    fn accepts_absolute_path_inside_workspace() {
        let inside = root().join("Cargo.toml");
        let resolved = resolve_in_workspace(inside.to_str().unwrap()).unwrap();
        assert_eq!(resolved, inside);
    }

    #[test]
    fn normalizes_interior_parent_segments() {
        let resolved = resolve_in_workspace("src/../Cargo.toml").unwrap();
        assert_eq!(resolved, root().join("Cargo.toml"));
    }

    #[test]
    fn rejects_new_file_behind_symlinked_parent() {
        let link = root().join("target/tmp-escape-link");
        std::fs::create_dir_all(root().join("target")).unwrap();
        let _ = std::fs::remove_file(&link);

        #[cfg(unix)]
        std::os::unix::fs::symlink("/tmp", &link).unwrap();

        let result = resolve_in_workspace("target/tmp-escape-link/pwned.txt");
        std::fs::remove_file(&link).unwrap();

        let err = result.expect_err("a new file behind a symlinked parent must be rejected");
        assert!(
            err.contains("symlink"),
            "expected the symlink guard to reject it, got: {}",
            err
        );
    }

    #[test]
    fn rejects_traversal_out_of_workspace() {
        for path in [
            "../../../../etc/passwd",
            "src/../../escape.txt",
            "/etc/passwd",
            "/home/other/.ssh/authorized_keys",
        ] {
            assert!(
                resolve_in_workspace(path).is_err(),
                "should have rejected {}",
                path
            );
        }
    }
}