qq-cli 0.1.0

A modular Rust QQ task interface for coding agents
use qq_cli::{napcat::qq, native::platform};
use std::time::{Duration, Instant};

#[path = "common/asar_fixture.rs"]
mod asar_fixture;
#[path = "common/qq_fixture.rs"]
mod fixture;
fn deadline() -> Instant {
    Instant::now() + Duration::from_secs(5)
}

#[test]
fn asar_entry_is_verified_in_both_layouts_and_entire_archive_changes_invalidate_it() {
    for alternate in [false, true] {
        let temp = tempfile::tempdir().unwrap();
        let executable = fixture::fixture(temp.path(), alternate);
        let app = if alternate {
            temp.path()
                .join("resources/app/versions")
                .join(fixture::VERSION)
        } else {
            temp.path()
                .join("versions")
                .join(fixture::VERSION)
                .join("resources/app")
        };
        asar_fixture::write(&app, serde_json::json!({"offset":"0","size":4}), b"test");
        let first = qq::inspect(&executable, deadline()).unwrap();
        asar_fixture::write(&app, serde_json::json!({"offset":"0","size":4}), b"next");
        let next = qq::inspect(&executable, deadline()).unwrap();
        assert_ne!(first.entry_sha256, next.entry_sha256);
        assert_ne!(first.fingerprint, next.fingerprint);
    }
}

#[test]
fn asar_links_unpacked_missing_and_out_of_bounds_entries_are_rejected() {
    let temp = tempfile::tempdir().unwrap();
    let executable = fixture::fixture(temp.path(), false);
    let app = temp
        .path()
        .join("versions")
        .join(fixture::VERSION)
        .join("resources/app");
    for leaf in [
        serde_json::json!({"link":"other.js"}),
        serde_json::json!({"unpacked":true,"size":4}),
        serde_json::json!({"offset":"18446744073709551615","size":4}),
        serde_json::json!({"offset":"0","size":5}),
        serde_json::json!({"files":{}}),
    ] {
        asar_fixture::write(&app, leaf, b"test");
        assert_eq!(
            qq::inspect(&executable, deadline()).unwrap_err().code,
            "QQ_METADATA_UNVERIFIED"
        );
    }
    for bytes in [
        vec![],
        vec![0; 16],
        [4_u32, u32::MAX, 0, 0]
            .into_iter()
            .flat_map(u32::to_le_bytes)
            .collect(),
    ] {
        std::fs::write(app.join("application.asar"), bytes).unwrap();
        assert_eq!(
            qq::inspect(&executable, deadline()).unwrap_err().code,
            "QQ_METADATA_UNVERIFIED"
        );
    }
}

#[test]
fn both_recorded_layouts_are_inspected_without_exposing_local_paths_or_unknown_fields() {
    for alternate in [false, true] {
        let temp = tempfile::tempdir().unwrap();
        let executable = fixture::fixture(temp.path(), alternate);
        let application = qq::inspect(&executable, deadline()).unwrap();
        assert_eq!(application.version, fixture::VERSION);
        assert_eq!(application.build, "60001");
        assert_eq!(application.architecture, "x86_64");
        let text = serde_json::to_string(&application).unwrap();
        assert!(!text.contains(temp.path().to_str().unwrap()));
        assert!(!text.contains("synthetic omitted field"));
    }
}

#[test]
fn entry_native_payload_and_metadata_changes_invalidate_the_fingerprint() {
    let temp = tempfile::tempdir().unwrap();
    let executable = fixture::fixture(temp.path(), false);
    let initial = qq::inspect(&executable, deadline()).unwrap();
    let app = temp
        .path()
        .join("versions")
        .join(fixture::VERSION)
        .join("resources/app");
    std::fs::write(app.join("launcher.js"), b"changed synthetic entry").unwrap();
    let entry = qq::inspect(&executable, deadline()).unwrap();
    assert_ne!(entry.fingerprint, initial.fingerprint);
    let mut payload = fixture::pe(0x8664);
    payload[127] = 1;
    std::fs::write(
        temp.path()
            .join("versions")
            .join(fixture::VERSION)
            .join("major.node"),
        payload,
    )
    .unwrap();
    let native = qq::inspect(&executable, deadline()).unwrap();
    assert_ne!(native.fingerprint, entry.fingerprint);
    std::fs::write(
        app.join("package.json"),
        r#"{"version":"9.9.99.60001","main":"launcher.js"}"#,
    )
    .unwrap();
    let metadata = qq::inspect(&executable, deadline()).unwrap();
    assert_eq!(metadata.build, "60001");
    assert_ne!(metadata.fingerprint, native.fingerprint);
}

#[test]
fn missing_disagreeing_or_traversing_versions_never_receive_a_default_build() {
    let temp = tempfile::tempdir().unwrap();
    let executable = fixture::fixture(temp.path(), false);
    let config = temp.path().join("versions/config.json");
    std::fs::write(&config, r#"{"curVersion":"../outside"}"#).unwrap();
    assert_eq!(
        qq::inspect(&executable, deadline()).unwrap_err().code,
        "QQ_METADATA_UNVERIFIED"
    );
    fixture::fixture(temp.path(), false);
    let package = temp
        .path()
        .join("versions")
        .join(fixture::VERSION)
        .join("resources/app/package.json");
    std::fs::write(
        package,
        r#"{"version":"9.9.99","buildVersion":"60002","main":"launcher.js"}"#,
    )
    .unwrap();
    assert_eq!(
        qq::inspect(&executable, deadline()).unwrap_err().code,
        "QQ_VERSION_MISMATCH"
    );
    std::fs::remove_file(config).unwrap();
    assert_eq!(
        qq::inspect(&executable, deadline()).unwrap_err().code,
        "QQ_METADATA_UNVERIFIED"
    );
}

#[test]
fn malformed_pe_architecture_mismatch_and_expired_hash_deadline_fail_closed() {
    let temp = tempfile::tempdir().unwrap();
    let executable = fixture::fixture(temp.path(), false);
    let major = temp
        .path()
        .join("versions")
        .join(fixture::VERSION)
        .join("major.node");
    std::fs::write(&major, fixture::pe(0xaa64)).unwrap();
    assert_eq!(
        qq::inspect(&executable, deadline()).unwrap_err().code,
        "QQ_ARCHITECTURE_MISMATCH"
    );
    let mut invalid = fixture::pe(0x8664);
    invalid[60..64].copy_from_slice(&u32::MAX.to_le_bytes());
    std::fs::write(&executable, invalid).unwrap();
    assert_eq!(
        qq::pe_architecture(&executable).unwrap_err().code,
        "QQ_METADATA_UNVERIFIED"
    );
    assert_eq!(
        qq::hash_file(&executable, 1024, Instant::now())
            .unwrap_err()
            .code,
        "DEADLINE"
    );
    assert_eq!(
        qq::hash_file(&executable, 10, deadline()).unwrap_err().code,
        "QQ_METADATA_UNVERIFIED"
    );
}

#[test]
fn uninstall_location_parsing_never_executes_arguments_or_accepts_an_unrelated_program() {
    let temp = tempfile::tempdir().unwrap();
    let uninstall = temp.path().join("Uninstall.exe");
    let expected = temp.path().join("QQ.exe");
    for command in [
        format!("\"{}\" --synthetic-unused-argument", uninstall.display()),
        uninstall.to_string_lossy().into_owned(),
    ] {
        assert_eq!(
            platform::executable_from_uninstall(&command).unwrap(),
            expected
        );
    }
    assert!(platform::executable_from_uninstall("relative-uninstall.exe").is_err());
    assert!(
        platform::executable_from_uninstall(&format!(
            "\"{}\"",
            temp.path().join("unrelated.exe").display()
        ))
        .is_err()
    );
}

#[cfg(windows)]
#[test]
fn native_host_probe_reads_version_and_native_architecture_without_application_access() {
    let host = platform::host();
    assert_eq!(host.os, "windows");
    assert!(host.os_build.is_some_and(|build| build > 0));
    assert!(!host.native_arch.is_empty());
    assert_eq!(host.process_arch, std::env::consts::ARCH);
}