ontocore-core 0.12.0

Core types and workspace scanner for OntoCore (ontocore-*)
Documentation
use crate::error::{OntoCoreError, Result};
use crate::limits::{MAX_FILE_BYTES, MAX_SCAN_FILES, MAX_SCAN_WALK_ENTRIES};
use crate::model::OntologyFormat;
use crate::path_jail::canonical_workspace_root;
use ignore::WalkBuilder;
use sha2::{Digest, Sha256};
use std::fs;
use std::path::{Path, PathBuf};
use std::time::UNIX_EPOCH;

const ONTOLOGY_EXTENSIONS: &[&str] =
    &["ttl", "rdf", "owl", "jsonld", "json-ld", "nt", "nq", "trig", "obo"];

#[derive(Debug, Clone)]
pub struct OntologyFile {
    pub path: PathBuf,
    pub format: OntologyFormat,
    pub content_hash: String,
    pub modified_time: u64,
    pub size_bytes: u64,
}

pub struct WorkspaceScanner {
    root: PathBuf,
    canonical_root: PathBuf,
}

impl WorkspaceScanner {
    pub fn new(root: impl Into<PathBuf>) -> Self {
        let root = root.into();
        let canonical_root = canonical_workspace_root(&root).unwrap_or_else(|_| root.clone());
        Self { root, canonical_root }
    }

    pub fn canonical_root(&self) -> &Path {
        &self.canonical_root
    }

    pub fn scan(&self) -> Result<Vec<OntologyFile>> {
        if !self.root.exists() {
            return Err(OntoCoreError::Scanner(format!(
                "workspace path does not exist: {}",
                self.root.display()
            )));
        }

        let mut files = Vec::new();
        let mut walked = 0usize;
        let walker = WalkBuilder::new(&self.root)
            .hidden(false)
            .git_ignore(true)
            .git_global(true)
            .git_exclude(true)
            .follow_links(false)
            .build();

        for entry in walker {
            walked += 1;
            if walked > MAX_SCAN_WALK_ENTRIES {
                return Err(OntoCoreError::Scanner(format!(
                    "workspace walk exceeds maximum of {MAX_SCAN_WALK_ENTRIES} entries"
                )));
            }
            if files.len() >= MAX_SCAN_FILES {
                return Err(OntoCoreError::Scanner(format!(
                    "workspace exceeds maximum of {MAX_SCAN_FILES} ontology files"
                )));
            }

            let entry = entry.map_err(|e| OntoCoreError::Scanner(e.to_string()))?;
            let path = entry.path();
            if !path.is_file() {
                continue;
            }
            if path.symlink_metadata().map(|m| m.file_type().is_symlink()).unwrap_or(false) {
                continue;
            }
            if let Some(ext) = path.extension().and_then(|e| e.to_str()) {
                if ONTOLOGY_EXTENSIONS.contains(&ext.to_ascii_lowercase().as_str()) {
                    let canonical =
                        path.canonicalize().map_err(|e| OntoCoreError::Scanner(e.to_string()))?;
                    if !canonical.starts_with(&self.canonical_root) {
                        continue;
                    }
                    files.push(self.describe_file(&canonical)?);
                }
            }
        }

        files.sort_by(|a, b| a.path.cmp(&b.path));
        Ok(files)
    }

    /// Describe a single ontology file on disk (hash, format, mtime).
    pub fn describe_path(&self, path: &Path) -> Result<OntologyFile> {
        let canonical = path.canonicalize().map_err(|e| OntoCoreError::Scanner(e.to_string()))?;
        if !canonical.starts_with(&self.canonical_root) {
            return Err(OntoCoreError::Scanner(format!(
                "path outside workspace: {}",
                path.display()
            )));
        }
        self.describe_file(&canonical)
    }

    fn describe_file(&self, path: &Path) -> Result<OntologyFile> {
        let metadata = fs::metadata(path)?;
        let size_bytes = metadata.len();
        if size_bytes > MAX_FILE_BYTES {
            return Err(OntoCoreError::Scanner(format!(
                "file exceeds size limit ({} bytes > {MAX_FILE_BYTES}): {}",
                size_bytes,
                path.display()
            )));
        }

        let content = crate::io::read_file_capped(path, MAX_FILE_BYTES)?;
        let mut hasher = Sha256::new();
        hasher.update(&content);
        let content_hash = hex::encode(hasher.finalize());

        let modified_time = metadata
            .modified()
            .ok()
            .and_then(|t| t.duration_since(UNIX_EPOCH).ok())
            .map(|d| d.as_secs())
            .unwrap_or(0);

        let ext = path.extension().and_then(|e| e.to_str()).unwrap_or_default();

        Ok(OntologyFile {
            path: path.to_path_buf(),
            format: OntologyFormat::from_extension(ext),
            content_hash,
            modified_time,
            size_bytes,
        })
    }
}

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

    #[test]
    fn scan_finds_ontology_files() {
        let dir = tempfile::tempdir().unwrap();
        let ttl = dir.path().join("example.ttl");
        let mut f = fs::File::create(&ttl).unwrap();
        writeln!(f, "@prefix ex: <http://example.org/> .").unwrap();

        let txt = dir.path().join("readme.txt");
        fs::write(txt, "not ontology").unwrap();

        let scanner = WorkspaceScanner::new(dir.path());
        let files = scanner.scan().unwrap();
        assert_eq!(files.len(), 1);
        assert_eq!(files[0].format, OntologyFormat::Turtle);
        assert!(!files[0].content_hash.is_empty());
    }

    #[test]
    fn skips_symlinked_ontology_files() {
        let dir = tempfile::tempdir().unwrap();
        let outside = tempfile::tempdir().unwrap();
        let secret = outside.path().join("secret.ttl");
        fs::write(&secret, "@prefix ex: <http://ex/> .").unwrap();

        #[cfg(unix)]
        {
            use std::os::unix::fs::symlink;
            let link = dir.path().join("linked.ttl");
            symlink(&secret, &link).unwrap();
            let scanner = WorkspaceScanner::new(dir.path());
            let files = scanner.scan().unwrap();
            assert_eq!(files.len(), 0);
        }
    }
}