qq-cli 0.1.0

A modular Rust QQ task interface for coding agents
use std::io::{Cursor, Write};
use std::time::{Duration, Instant};

use qq_cli::napcat::install::archive::{self, Expected};
use sha2::{Digest, Sha256};
use zip::write::SimpleFileOptions;

fn zip(entries: &[(&str, &[u8])]) -> Vec<u8> {
    let mut writer = zip::ZipWriter::new(Cursor::new(Vec::new()));
    for (path, bytes) in entries {
        writer
            .start_file(
                *path,
                SimpleFileOptions::default().compression_method(zip::CompressionMethod::Deflated),
            )
            .unwrap();
        writer.write_all(bytes).unwrap();
    }
    writer.finish().unwrap().into_inner()
}
fn digest(bytes: &[u8]) -> String {
    format!("{:x}", Sha256::digest(bytes))
}
fn deadline() -> Instant {
    Instant::now() + Duration::from_secs(5)
}

#[test]
fn verified_archive_has_an_owned_manifest_and_failure_leaves_no_partial_tree() {
    let temp = tempfile::tempdir().unwrap();
    let bytes = zip(&[
        ("loader.exe", b"synthetic loader bytes"),
        ("native/module.node", b"synthetic native bytes"),
    ]);
    let hash = digest(&bytes);
    let loader = digest(b"synthetic loader bytes");
    let expected = Expected {
        bytes: bytes.len() as u64,
        sha256: &hash,
        files: 2,
        required: &[("loader.exe", &loader)],
    };
    let prepared =
        archive::prepare(Cursor::new(&bytes), &expected, temp.path(), deadline()).unwrap();
    assert_eq!(prepared.members.len(), 2);
    assert_eq!(prepared.members[0].sha256, loader);
    assert_eq!(
        std::fs::read(prepared.directory.path().join("native/module.node")).unwrap(),
        b"synthetic native bytes"
    );
    drop(prepared);
    assert_eq!(std::fs::read_dir(temp.path()).unwrap().count(), 0);
    let bad = Expected {
        sha256: &"0".repeat(64),
        ..expected
    };
    assert_eq!(
        archive::prepare(Cursor::new(&bytes), &bad, temp.path(), deadline())
            .err()
            .unwrap()
            .code,
        "ARTIFACT_MISMATCH"
    );
    assert_eq!(std::fs::read_dir(temp.path()).unwrap().count(), 0);
}

#[test]
fn traversal_windows_aliases_case_collisions_and_symlinks_are_rejected() {
    for name in [
        "../outside",
        "/absolute",
        "C:relative",
        "a\\b",
        "a/../b",
        "a//b",
        "a/./b",
        "a:stream",
        "aux.txt",
        "COM1.node",
        "a. ",
        ".qq-cli-installation.json",
    ] {
        assert!(archive::member_path(name).is_err(), "{name}");
    }
    let temp = tempfile::tempdir().unwrap();
    let mut cases = vec![
        zip(&[("Name.txt", b"one"), ("name.txt", b"two")]),
        zip(&[("Native/one.node", b"one"), ("native/two.node", b"two")]),
        zip(&[("../outside.txt", b"synthetic")]),
    ];
    let mut writer = zip::ZipWriter::new(Cursor::new(Vec::new()));
    writer
        .add_symlink("link", "../outside", SimpleFileOptions::default())
        .unwrap();
    cases.push(writer.finish().unwrap().into_inner());
    for bytes in cases {
        let hash = digest(&bytes);
        let expected = Expected {
            bytes: bytes.len() as u64,
            sha256: &hash,
            files: 1,
            required: &[],
        };
        assert_eq!(
            archive::prepare(Cursor::new(bytes), &expected, temp.path(), deadline())
                .err()
                .unwrap()
                .code,
            "ARCHIVE_UNSAFE"
        );
        assert_eq!(std::fs::read_dir(temp.path()).unwrap().count(), 0);
    }
}

#[test]
fn missing_pinned_members_file_count_deadline_and_resource_limits_fail_closed() {
    let temp = tempfile::tempdir().unwrap();
    let bytes = zip(&[("file.txt", b"sample")]);
    let hash = digest(&bytes);
    let expected = Expected {
        bytes: bytes.len() as u64,
        sha256: &hash,
        files: 1,
        required: &[("missing.exe", &hash)],
    };
    assert_eq!(
        archive::prepare(Cursor::new(&bytes), &expected, temp.path(), deadline())
            .err()
            .unwrap()
            .code,
        "ARTIFACT_INCOMPLETE"
    );
    let expected = Expected {
        required: &[],
        ..expected
    };
    assert_eq!(
        archive::prepare(Cursor::new(&bytes), &expected, temp.path(), Instant::now())
            .err()
            .unwrap()
            .code,
        "DEADLINE"
    );
    let huge = Expected {
        bytes: 65 * 1024 * 1024,
        ..expected
    };
    assert_eq!(
        archive::prepare(Cursor::new(&bytes), &huge, temp.path(), deadline())
            .err()
            .unwrap()
            .code,
        "ARCHIVE_UNSAFE"
    );
    let wrong_count = Expected {
        files: 2,
        ..expected
    };
    assert_eq!(
        archive::prepare(Cursor::new(&bytes), &wrong_count, temp.path(), deadline())
            .err()
            .unwrap()
            .code,
        "ARCHIVE_UNSAFE"
    );
    assert_eq!(std::fs::read_dir(temp.path()).unwrap().count(), 0);
    let position = bytes
        .windows(4)
        .position(|window| window == b"PK\x01\x02")
        .unwrap();
    for (offset, value) in [(16, 0_u32), (24, 129 * 1024 * 1024)] {
        let mut invalid = bytes.clone();
        invalid[position + offset..position + offset + 4].copy_from_slice(&value.to_le_bytes());
        let hash = digest(&invalid);
        let expected = Expected {
            bytes: invalid.len() as u64,
            sha256: &hash,
            files: 1,
            required: &[],
        };
        assert_eq!(
            archive::prepare(Cursor::new(invalid), &expected, temp.path(), deadline())
                .err()
                .unwrap()
                .code,
            "ARCHIVE_UNSAFE"
        );
        assert_eq!(std::fs::read_dir(temp.path()).unwrap().count(), 0);
    }
}

#[test]
fn catalog_is_exact_and_records_unresolved_authorization_without_treating_download_as_permission() {
    for version in ["latest", "4.18.39", "vv4.18.38", ""] {
        assert!(qq_cli::napcat::install::catalog::lookup(version).is_err());
    }
    for version in ["v4.18.38", "4.18.38", "v4.18.37", "4.18.37"] {
        let release = qq_cli::napcat::install::catalog::lookup(version).unwrap();
        assert_eq!(release.archive.files, 680);
        assert_eq!(
            release.archive.bytes,
            if release.version == "v4.18.38" {
                36_728_573
            } else {
                36_723_625
            }
        );
        assert!(release.authorization_evidence.is_none());
        assert!(
            release
                .archive
                .required
                .iter()
                .all(|(_, hash)| hash.len() == 64)
        );
    }
    let current = qq_cli::napcat::install::catalog::lookup("4.18.38").unwrap();
    let previous = qq_cli::napcat::install::catalog::lookup("4.18.37").unwrap();
    assert_ne!(current.commit, previous.commit);
    assert_ne!(current.archive.sha256, previous.archive.sha256);
    let payload = |release: &qq_cli::napcat::install::catalog::Release| {
        release
            .archive
            .required
            .iter()
            .find(|(name, _)| *name == "napcat.mjs")
            .unwrap()
            .1
    };
    assert_ne!(payload(&current), payload(&previous));
}