keyhog-sources 0.5.43

keyhog-sources: pluggable input backends for KeyHog (git, S3, GCS, Azure Blob, Docker, Web)
Documentation
use std::collections::VecDeque;

use keyhog_core::{Chunk, ChunkMetadata, SourceError};

use super::{git_unscanned_object_error, parse_git_object_id_line, record_git_object_unreadable};

#[derive(Debug, Clone)]
pub(crate) struct GitTagMessageRef {
    oid: gix::ObjectId,
    path: String,
    source_type: &'static str,
}

pub(crate) fn collect_reachable_tag_messages(
    repo_arg: &str,
) -> Result<VecDeque<GitTagMessageRef>, SourceError> {
    let mut command = super::git_command()?;
    command.args([
        "-C",
        repo_arg,
        "for-each-ref",
        "refs/tags",
        "--format=%(objecttype) %(objectname) %(refname)",
    ]);
    command.stdout(std::process::Stdio::piped());
    command.stderr(std::process::Stdio::piped());

    let mut child = super::spawn_git_child(command)?;
    let stdout = child
        .take_stdout()
        .ok_or_else(|| SourceError::Io(std::io::Error::other("missing for-each-ref stdout")))?;
    let mut reader = std::io::BufReader::new(stdout);
    let mut tags = VecDeque::new();
    let mut line_buf = Vec::new();
    loop {
        let consumed =
            super::read_capped_line(&mut reader, &mut line_buf, super::GIT_PLUMBING_LINE_BYTES)
                .map_err(SourceError::Io)?;
        if consumed == 0 {
            break;
        }
        if consumed > super::GIT_PLUMBING_LINE_BYTES {
            return Err(super::git_output_line_truncated_error(
                "git tag source",
                "tag ref line",
                super::GIT_PLUMBING_LINE_BYTES,
                consumed,
            ));
        }
        let line = String::from_utf8_lossy(&line_buf);
        let line = line.trim_end_matches('\n').trim_end_matches('\r');
        let mut parts = line.splitn(3, ' ');
        let Some(object_type) = parts.next() else {
            continue;
        };
        if object_type != "tag" {
            continue;
        }
        let Some(object_id) = parts.next() else {
            continue;
        };
        let Some(ref_name) = parts.next() else {
            continue;
        };
        let Some(oid) = parse_git_object_id_line(object_id, "tag") else {
            continue;
        };
        tags.push_back(GitTagMessageRef {
            oid,
            path: ref_name.to_owned(),
            source_type: "git/tag",
        });
    }
    super::wait_for_git_child(
        &mut child,
        "git for-each-ref",
        "enumerating annotated git tags",
    )?;
    Ok(tags)
}

pub(crate) fn decode_unreachable_tag_message_chunks(
    repo: &gix::Repository,
    tags: &mut VecDeque<gix::ObjectId>,
    limits: crate::SourceLimits,
    total_bytes: &mut usize,
    chunk_count: &mut usize,
    errors: &mut VecDeque<SourceError>,
) -> VecDeque<Chunk> {
    let mut chunks = VecDeque::new();
    while super::git_history_cap_status(*total_bytes, *chunk_count, limits).is_none() {
        let Some(oid) = tags.pop_front() else {
            break;
        };
        let tag_ref = GitTagMessageRef {
            oid,
            path: format!(".git/unreachable/{oid}"),
            source_type: "git/unreachable",
        };
        let chunk = match decode_tag_message_chunk(repo, tag_ref, limits) {
            Ok(Some(chunk)) => chunk,
            Ok(None) => continue,
            Err(error) => {
                errors.push_back(error);
                continue;
            }
        };
        *total_bytes = total_bytes.saturating_add(chunk.data.len());
        *chunk_count += 1;
        chunks.push_back(chunk);
    }
    chunks
}

pub(crate) fn decode_tag_message_chunks(
    repo: &gix::Repository,
    tags: &mut VecDeque<GitTagMessageRef>,
    limits: crate::SourceLimits,
    total_bytes: &mut usize,
    chunk_count: &mut usize,
    errors: &mut VecDeque<SourceError>,
) -> VecDeque<Chunk> {
    let mut chunks = VecDeque::new();
    while super::git_history_cap_status(*total_bytes, *chunk_count, limits).is_none() {
        let Some(tag_ref) = tags.pop_front() else {
            break;
        };
        let chunk = match decode_tag_message_chunk(repo, tag_ref, limits) {
            Ok(Some(chunk)) => chunk,
            Ok(None) => continue,
            Err(error) => {
                errors.push_back(error);
                continue;
            }
        };
        *total_bytes = total_bytes.saturating_add(chunk.data.len());
        *chunk_count += 1;
        chunks.push_back(chunk);
    }
    chunks
}

fn decode_tag_message_chunk(
    repo: &gix::Repository,
    tag_ref: GitTagMessageRef,
    limits: crate::SourceLimits,
) -> Result<Option<Chunk>, SourceError> {
    let obj = match repo.find_object(tag_ref.oid) {
        Ok(obj) => obj,
        Err(error) => {
            tracing::warn!(
                %error,
                tag = %tag_ref.oid,
                "git tag object unreadable; tag message was NOT scanned"
            );
            record_git_object_unreadable();
            return Err(git_unscanned_object_error(format!(
                "git tag object {} ({}) unreadable ({error}); tag message was not scanned",
                tag_ref.oid, tag_ref.path
            )));
        }
    };
    let tag = match obj.try_into_tag() {
        Ok(tag) => tag,
        Err(error) => {
            tracing::warn!(
                %error,
                tag = %tag_ref.oid,
                "git object is not an annotated tag; tag message was NOT scanned"
            );
            record_git_object_unreadable();
            return Err(git_unscanned_object_error(format!(
                "git object {} ({}) is not an annotated tag ({error}); tag message was not scanned",
                tag_ref.oid, tag_ref.path
            )));
        }
    };
    let decoded = match tag.decode() {
        Ok(decoded) => decoded,
        Err(error) => {
            tracing::warn!(
                %error,
                tag = %tag_ref.oid,
                "git tag object could not be decoded; tag message was NOT scanned"
            );
            record_git_object_unreadable();
            return Err(git_unscanned_object_error(format!(
                "git tag object {} ({}) could not be decoded ({error}); tag message was not scanned",
                tag_ref.oid, tag_ref.path
            )));
        }
    };
    let message_bytes: &[u8] = decoded.message.as_ref();
    if message_bytes.is_empty() {
        return Ok(None);
    }
    if message_bytes.len() as u64 > limits.git_blob_bytes {
        tracing::warn!(
            tag = %tag_ref.oid,
            size = message_bytes.len(),
            cap = limits.git_blob_bytes,
            "git tag message exceeds the per-blob size cap; NOT scanned"
        );
        let _event = crate::record_skip_event(crate::SourceSkipEvent::OverMaxSize);
        return Err(git_unscanned_object_error(format!(
            "git tag message {} ({}) exceeded the {}-byte per-blob size cap; tag message was not scanned",
            tag_ref.oid, tag_ref.path, limits.git_blob_bytes
        )));
    }
    let Some(file_text) = crate::filesystem::decode_text_file(message_bytes) else {
        let _event = crate::record_skip_event(crate::SourceSkipEvent::Binary);
        return Err(git_unscanned_object_error(format!(
            "git tag message {} ({}) was binary or undecodable text; tag message was not scanned",
            tag_ref.oid, tag_ref.path
        )));
    };
    let author = match decoded.tagger() {
        Ok(Some(tagger)) => {
            let name = String::from_utf8_lossy(tagger.name.as_ref())
                .trim()
                .to_string();
            if name.is_empty() {
                None
            } else {
                Some(name)
            }
        }
        Ok(None) => None,
        Err(error) => {
            tracing::warn!(
                %error,
                tag = %tag_ref.oid,
                "git tag tagger metadata could not be decoded; tag message will be scanned without author"
            );
            None
        }
    };
    Ok(Some(Chunk {
        data: file_text.into(),
        metadata: ChunkMetadata {
            base_offset: 0,
            base_line: 0,
            source_type: tag_ref.source_type.into(),
            path: Some(tag_ref.path.into()),
            commit: None,
            author: author.map(Into::into),
            date: None,
            mtime_ns: None,
            size_bytes: Some(message_bytes.len() as u64),
            decoded_span: None,
        },
    }))
}