deslop 0.2.0

A static analyzer that spots low-context and AI-assisted code patterns across naming, concurrency, security, performance, and test quality.
Documentation
use std::fs::{File, OpenOptions, symlink_metadata};
use std::io::{BufReader, Read};
use std::path::{Path, PathBuf};

#[cfg(unix)]
use std::os::unix::fs::OpenOptionsExt;

use crate::{Error, Result};

pub const DEFAULT_MAX_BYTES: u64 = 10 * 1024 * 1024;

pub fn read_to_string_limited(path: &Path, max_bytes: u64) -> Result<String> {
    reject_symlink(path)?;
    let file = open_readonly(path)?;
    let size = file
        .metadata()
        .map_err(|error| Error::io(path, error))?
        .len();

    if size > max_bytes {
        return Err(Error::InputTooLarge {
            path: path.to_path_buf(),
            size,
            max_bytes,
        });
    }

    let mut reader = BufReader::new(file);
    let mut source = String::new();
    let bytes_read = u64::try_from(
        reader
            .by_ref()
            .take(max_bytes + 1)
            .read_to_string(&mut source)
            .map_err(|error| Error::io(path, error))?,
    )
    .map_err(|_| Error::byte_count_overflow(path, source.len()))?;

    if bytes_read > max_bytes {
        return Err(Error::InputTooLarge {
            path: path.to_path_buf(),
            size: bytes_read,
            max_bytes,
        });
    }

    Ok(source)
}

pub(crate) fn canonicalize_within_root(root: &Path, candidate: &Path) -> Result<PathBuf> {
    let canonical_root = root
        .canonicalize()
        .map_err(|error| Error::io(root, error))?;
    reject_symlink(candidate)?;
    let canonical_path = candidate
        .canonicalize()
        .map_err(|error| Error::io(candidate, error))?;

    if !canonical_path.starts_with(&canonical_root) {
        return Err(Error::path_outside_root(canonical_path, canonical_root));
    }

    Ok(canonical_path)
}

fn reject_symlink(path: &Path) -> Result<()> {
    let metadata = symlink_metadata(path).map_err(|error| Error::io(path, error))?;
    if metadata.file_type().is_symlink() {
        return Err(Error::symlink_rejected(path));
    }

    Ok(())
}

#[cfg(unix)]
fn open_readonly(path: &Path) -> Result<File> {
    OpenOptions::new()
        .read(true)
        .custom_flags(libc::O_NOFOLLOW)
        .open(path)
        .map_err(|error| Error::io(path, error))
}

#[cfg(not(unix))]
fn open_readonly(path: &Path) -> Result<File> {
    File::open(path).map_err(|error| Error::io(path, error))
}

#[cfg(test)]
mod tests {
    use std::fs;

    use proptest::prelude::*;
    use tempfile::{Builder, tempdir};

    use super::read_to_string_limited;
    use crate::Error;

    fn temp_file(name: &str) -> tempfile::NamedTempFile {
        Builder::new()
            .prefix(&format!("deslop-io-{name}-"))
            .tempfile()
            .expect("temp file should be created")
    }

    #[test]
    fn reads_small_file() {
        let path = temp_file("small");
        fs::write(path.path(), "hello world").expect("temp file write should succeed");

        let contents = read_to_string_limited(path.path(), 64).expect("small file should read");
        assert_eq!(contents, "hello world");
    }

    #[test]
    fn rejects_oversized_file() {
        let path = temp_file("large");
        fs::write(path.path(), "0123456789abcdef").expect("temp file write should succeed");

        let error = read_to_string_limited(path.path(), 4).expect_err("oversized file should fail");
        assert!(matches!(error, Error::InputTooLarge { .. }));
    }

    #[test]
    fn accepts_input_at_exact_limit() {
        let path = temp_file("exact-limit");
        fs::write(path.path(), "1234").expect("temp file write should succeed");

        let contents = read_to_string_limited(path.path(), 4).expect("exact limit should succeed");
        assert_eq!(contents, "1234");
    }

    #[cfg(unix)]
    #[test]
    fn rejects_symlink_input() {
        use std::os::unix::fs::symlink;

        let root = tempdir().expect("temp dir should be created");
        let target = root.path().join("target.txt");
        let link = root.path().join("link.txt");
        fs::write(&target, "hello").expect("target file write should succeed");
        symlink(&target, &link).expect("symlink should be created");

        let error = read_to_string_limited(&link, 32).expect_err("symlinked file should fail");
        assert!(matches!(error, Error::SymlinkRejected { .. }));
    }

    proptest! {
        #[test]
        fn bounded_reads_track_input_size(len in 0usize..256, limit in 0usize..256) {
            let path = temp_file("prop-limit");
            let contents = "x".repeat(len);
            fs::write(path.path(), &contents).expect("temp file write should succeed");

            let result = read_to_string_limited(
                path.path(),
                u64::try_from(limit).expect("proptest limit should fit into u64"),
            );
            prop_assert_eq!(result.is_ok(), len <= limit);

            if let Ok(read_back) = result {
                prop_assert_eq!(read_back.len(), len);
            }
        }
    }
}