// Copyright 2018-2026 the Deno authors. MIT license.

use std::borrow::Cow;
use std::collections::HashSet;
use std::io::ErrorKind;
use std::io::Read;
use std::io::Write;
use std::path::Path;
use std::path::PathBuf;

use base64::Engine;
use base64::prelude::BASE64_STANDARD;
use deno_npm::registry::NpmPackageVersionDistInfo;
use deno_npm::registry::NpmPackageVersionDistInfoIntegrity;
use deno_semver::package::PackageNv;
use flate2::read::GzDecoder;
use sys_traits::FsCanonicalize;
use sys_traits::FsCreateDirAll;
use sys_traits::FsFileSetPermissions;
use sys_traits::FsMetadata;
use sys_traits::FsOpen;
use sys_traits::FsRemoveDirAll;
use sys_traits::FsRemoveFile;
use sys_traits::FsRename;
use sys_traits::OpenOptions;
use sys_traits::PathsInErrorsExt;
use sys_traits::SystemRandom;
use sys_traits::ThreadSleep;
use tar::Archive;
use tar::EntryType;

#[cfg(target_arch = "wasm32")]
mod hashing {
  use sha2::Digest;

  pub fn sha1(data: &[u8]) -> impl AsRef<[u8]> {
    sha1::Sha1::digest(data)
  }

  pub fn sha512(data: &[u8]) -> impl AsRef<[u8]> {
    sha2::Sha512::digest(data)
  }
}

#[cfg(not(target_arch = "wasm32"))]
mod hashing {
  use aws_lc_rs::digest;
  pub fn sha1(data: &[u8]) -> impl AsRef<[u8]> {
    digest::digest(&digest::SHA1_FOR_LEGACY_USE_ONLY, data)
  }

  pub fn sha512(data: &[u8]) -> impl AsRef<[u8]> {
    digest::digest(&digest::SHA512, data)
  }
}

#[derive(Debug, Copy, Clone)]
pub enum TarballExtractionMode {
  /// Overwrites the destination directory without deleting any files.
  Overwrite,
  /// Creates and writes to a sibling temporary directory. When done, moves
  /// it to the final destination.
  ///
  /// This is more robust than `Overwrite` as it better handles multiple
  /// processes writing to the directory at the same time.
  SiblingTempDir,
}

#[derive(Debug, thiserror::Error, deno_error::JsError)]
pub enum VerifyAndExtractTarballError {
  #[class(inherit)]
  #[error(transparent)]
  TarballIntegrity(#[from] TarballIntegrityError),
  #[class(inherit)]
  #[error(transparent)]
  ExtractTarball(#[from] ExtractTarballError),
  #[class(inherit)]
  #[error("Failed moving extracted tarball to final destination")]
  MoveFailed(std::io::Error),
}

/// Verifies the tarball integrity and decompresses gzip to raw tar bytes.
/// This is CPU-bound work that can safely run in parallel.
pub fn verify_and_decompress_tarball(
  package_nv: &PackageNv,
  data: &[u8],
  dist_info: &NpmPackageVersionDistInfo,
) -> Result<Vec<u8>, VerifyAndExtractTarballError> {
  verify_tarball_integrity(package_nv, data, &dist_info.integrity())?;
  decompress_gzip(data)
}

/// Uses libdeflater for faster gzip decompression with preallocated buffers.
#[cfg(not(target_arch = "wasm32"))]
fn decompress_gzip(
  data: &[u8],
) -> Result<Vec<u8>, VerifyAndExtractTarballError> {
  use libdeflater::Decompressor;

  let mut decompressor = Decompressor::new();
  let size_hint = gzip_decompressed_size_hint(data);
  let mut decompressed = Vec::new();
  if decompressed.try_reserve(size_hint).is_err() {
    return decompress_gzip_streaming(data);
  }
  // SAFETY: we reserved `size_hint` bytes above and libdeflate will
  // write into this buffer, then we truncate to the actual size.
  unsafe { decompressed.set_len(size_hint) };
  match decompressor.gzip_decompress(data, &mut decompressed) {
    Ok(actual_size) => {
      decompressed.truncate(actual_size);
      Ok(decompressed)
    }
    Err(_) => {
      // Bad data or insufficient space — fall back
      // to flate2 for a proper io::Error.
      decompress_gzip_streaming(data)
    }
  }
}

#[cfg(target_arch = "wasm32")]
fn decompress_gzip(
  data: &[u8],
) -> Result<Vec<u8>, VerifyAndExtractTarballError> {
  decompress_gzip_streaming(data)
}

fn decompress_gzip_streaming(
  data: &[u8],
) -> Result<Vec<u8>, VerifyAndExtractTarballError> {
  let mut decoder = GzDecoder::new(data);
  let mut decompressed = Vec::new();
  let _ = decompressed.try_reserve(gzip_decompressed_size_hint(data));
  decoder
    .read_to_end(&mut decompressed)
    .map_err(ExtractTarballError::from)?;
  Ok(decompressed)
}

/// Read the uncompressed size hint from the gzip trailer (last 4 bytes).
/// This is the original size mod 2^32, so it's exact for data < 4GB
/// (which covers virtually all npm packages).
fn gzip_decompressed_size_hint(data: &[u8]) -> usize {
  if data.len() >= 4 {
    let last4 = &data[data.len() - 4..];
    u32::from_le_bytes([last4[0], last4[1], last4[2], last4[3]]) as usize
  } else {
    data.len().saturating_mul(4)
  }
}

/// Writes already-decompressed raw tar bytes to disk.
/// This is I/O-bound work that benefits from limited concurrency
/// to avoid filesystem contention.
pub fn write_extracted_tarball(
  sys: &(
     impl FsCanonicalize
     + FsCreateDirAll
     + FsMetadata
     + FsOpen
     + FsRename
     + FsRemoveDirAll
     + FsRemoveFile
     + SystemRandom
     + ThreadSleep
   ),
  tar_data: &[u8],
  output_folder: &Path,
  extraction_mode: TarballExtractionMode,
) -> Result<(), VerifyAndExtractTarballError> {
  match extraction_mode {
    TarballExtractionMode::Overwrite => {
      extract_tarball(sys, tar_data, output_folder).map_err(Into::into)
    }
    TarballExtractionMode::SiblingTempDir => {
      let temp_dir = deno_path_util::get_atomic_path(sys, output_folder);
      extract_tarball(sys, tar_data, &temp_dir)?;
      rename_with_retries(sys, &temp_dir, output_folder)
        .map_err(VerifyAndExtractTarballError::MoveFailed)
    }
  }
}

fn rename_with_retries(
  sys: &(impl ThreadSleep + FsMetadata + FsRemoveDirAll + FsRename),
  temp_dir: &Path,
  output_folder: &Path,
) -> Result<(), std::io::Error> {
  fn already_exists(
    sys: &impl FsMetadata,
    err: &std::io::Error,
    output_folder: &Path,
  ) -> bool {
    // Windows will do an "Access is denied" error
    err.kind() == ErrorKind::AlreadyExists
      || sys.fs_exists_no_err(output_folder)
  }

  let mut count = 0;
  // renaming might be flaky if a lot of processes are trying
  // to do this, so retry a few times
  loop {
    match sys.fs_rename(temp_dir, output_folder) {
      Ok(_) => return Ok(()),
      Err(err) if already_exists(sys, &err, output_folder) => {
        // another process copied here, just cleanup
        let _ = sys.fs_remove_dir_all(temp_dir);
        return Ok(());
      }
      Err(err) => {
        count += 1;
        if count > 5 {
          // too many retries, cleanup and return the error
          let _ = sys.fs_remove_dir_all(temp_dir);
          return Err(err);
        }

        // wait a bit before retrying... this should be very rare or only
        // in error cases, so ok to sleep a bit
        let sleep_ms = std::cmp::min(100, 20 * count);
        sys.thread_sleep(std::time::Duration::from_millis(sleep_ms));
      }
    }
  }
}

#[derive(Debug, thiserror::Error, deno_error::JsError)]
#[class(generic)]
pub enum TarballIntegrityError {
  #[error("Not implemented hash function for {package}: {hash_kind}")]
  NotImplementedHashFunction {
    package: Box<PackageNv>,
    hash_kind: String,
  },
  #[error("Not implemented integrity kind for {package}: {integrity}")]
  NotImplementedIntegrityKind {
    package: Box<PackageNv>,
    integrity: String,
  },
  #[error(
    "Tarball checksum did not match what was provided by npm registry for {package}.\n\nExpected: {expected}\nActual: {actual}"
  )]
  MismatchedChecksum {
    package: Box<PackageNv>,
    expected: String,
    actual: String,
  },
}

fn verify_tarball_integrity(
  package: &PackageNv,
  data: &[u8],
  npm_integrity: &NpmPackageVersionDistInfoIntegrity,
) -> Result<(), TarballIntegrityError> {
  let (tarball_checksum, expected_checksum) = match npm_integrity {
    NpmPackageVersionDistInfoIntegrity::Integrity {
      algorithm,
      base64_hash,
    } => {
      let tarball_checksum = match *algorithm {
        "sha512" => BASE64_STANDARD.encode(hashing::sha512(data)),
        "sha1" => BASE64_STANDARD.encode(hashing::sha1(data)),
        hash_kind => {
          return Err(TarballIntegrityError::NotImplementedHashFunction {
            package: Box::new(package.clone()),
            hash_kind: hash_kind.to_string(),
          });
        }
      };
      (tarball_checksum, base64_hash)
    }
    NpmPackageVersionDistInfoIntegrity::LegacySha1Hex(hex) => {
      let digest = hashing::sha1(data);
      let tarball_checksum = faster_hex::hex_string(digest.as_ref());
      (tarball_checksum, hex)
    }
    NpmPackageVersionDistInfoIntegrity::UnknownIntegrity(integrity) => {
      return Err(TarballIntegrityError::NotImplementedIntegrityKind {
        package: Box::new(package.clone()),
        integrity: integrity.to_string(),
      });
    }
    NpmPackageVersionDistInfoIntegrity::None => {
      return Ok(());
    }
  };

  if tarball_checksum != *expected_checksum {
    return Err(TarballIntegrityError::MismatchedChecksum {
      package: Box::new(package.clone()),
      expected: expected_checksum.to_string(),
      actual: tarball_checksum,
    });
  }
  Ok(())
}

#[derive(Debug, thiserror::Error, deno_error::JsError)]
pub enum ExtractTarballError {
  #[class(inherit)]
  #[error(transparent)]
  Io(#[from] std::io::Error),
  #[class(generic)]
  #[error(
    "Extracted directory '{0}' of npm tarball was not in output directory."
  )]
  NotInOutputDirectory(PathBuf),
}

/// Extracts raw (already decompressed) tar bytes to the output folder.
fn extract_tarball(
  sys: &(impl FsCanonicalize + FsCreateDirAll + FsOpen + FsRemoveFile),
  tar_data: &[u8],
  output_folder: &Path,
) -> Result<(), ExtractTarballError> {
  let sys = sys.with_paths_in_errors();
  sys.fs_create_dir_all(output_folder)?;
  let mut archive = Archive::new(tar_data);
  archive.set_overwrite(true);
  archive.set_preserve_permissions(true);
  let mut created_dirs = HashSet::new();
  let mut absolute_path = output_folder.to_path_buf();

  for entry in archive.entries()? {
    let entry = entry?;
    let path = entry.path()?;
    let entry_type = entry.header().entry_type();

    // Some package tarballs contain "pax_global_header", these entries
    // should be skipped.
    if entry_type == EntryType::XGlobalHeader {
      continue;
    }

    // skip the first component which will be either "package" or the name of the package
    absolute_path.clear();
    absolute_path.push(output_folder);
    for component in path.components().skip(1) {
      absolute_path.push(component);
    }
    let dir_path = if entry_type == EntryType::Directory {
      absolute_path.as_path()
    } else {
      absolute_path.parent().unwrap()
    };
    if !created_dirs.contains(dir_path) {
      created_dirs.insert(dir_path.to_path_buf());
      sys.fs_create_dir_all(dir_path)?;
      let dir_path = deno_path_util::normalize_path(Cow::Borrowed(dir_path));
      if !dir_path.starts_with(output_folder) {
        return Err(ExtractTarballError::NotInOutputDirectory(
          dir_path.to_path_buf(),
        ));
      }
    }

    let entry_type = entry.header().entry_type();
    match entry_type {
      EntryType::Regular => {
        let open_options = OpenOptions::new_write();
        let mut f = sys.fs_open(&absolute_path, &open_options)?;
        let data_offset = entry.raw_file_position() as usize;
        let size = entry.header().size()? as usize;
        let end = data_offset.checked_add(size).ok_or_else(|| {
          std::io::Error::new(
            std::io::ErrorKind::InvalidData,
            format!(
              "tar entry '{}' has invalid offset/size (offset={}, size={})",
              absolute_path.display(),
              data_offset,
              size,
            ),
          )
        })?;
        let entry_data =
          tar_data.get(data_offset..end).ok_or_else(|| {
            std::io::Error::new(
              std::io::ErrorKind::UnexpectedEof,
              format!(
                "tar entry '{}' extends beyond archive (offset={}, size={}, archive_len={})",
                absolute_path.display(),
                data_offset,
                size,
                tar_data.len(),
              ),
            )
          })?;
        f.write_all(entry_data)?;
        if !sys_traits::impls::is_windows() {
          let mode = entry.header().mode()?;
          if mode != 0o644 {
            f.fs_file_set_permissions(mode)?;
          }
        }
      }
      EntryType::Symlink | EntryType::Link => {
        // At the moment, npm doesn't seem to support uploading hardlinks or
        // symlinks to the npm registry. If ever adding symlink or hardlink
        // support, we will need to validate that the hardlink and symlink
        // target are within the package directory.
        log::warn!(
          "Ignoring npm tarball entry type {:?} for '{}'",
          entry_type,
          absolute_path.display()
        )
      }
      _ => {
        // ignore
      }
    }
  }
  Ok(())
}

#[cfg(test)]
mod test {
  use deno_semver::Version;
  use sys_traits::FsMetadata;
  use sys_traits::FsWrite;
  use tempfile::TempDir;

  use super::*;

  #[test]
  pub fn test_verify_tarball() {
    let package = PackageNv {
      name: "package".into(),
      version: Version::parse_from_npm("1.0.0").unwrap(),
    };
    let actual_checksum = "z4PhNX7vuL3xVChQ1m2AB9Yg5AULVxXcg/SpIdNs6c5H0NE8XYXysP+DGNKHfuwvY7kxvUdBeoGlODJ6+SfaPg==";
    assert_eq!(
      verify_tarball_integrity(
        &package,
        &Vec::new(),
        &NpmPackageVersionDistInfoIntegrity::UnknownIntegrity("test")
      )
      .unwrap_err()
      .to_string(),
      "Not implemented integrity kind for package@1.0.0: test",
    );
    assert_eq!(
      verify_tarball_integrity(
        &package,
        &Vec::new(),
        &NpmPackageVersionDistInfoIntegrity::Integrity {
          algorithm: "notimplemented",
          base64_hash: "test"
        }
      )
      .unwrap_err()
      .to_string(),
      "Not implemented hash function for package@1.0.0: notimplemented",
    );
    assert_eq!(
      verify_tarball_integrity(
        &package,
        &Vec::new(),
        &NpmPackageVersionDistInfoIntegrity::Integrity {
          algorithm: "sha1",
          base64_hash: "test"
        }
      )
      .unwrap_err()
      .to_string(),
      concat!(
        "Tarball checksum did not match what was provided by npm ",
        "registry for package@1.0.0.\n\nExpected: test\nActual: 2jmj7l5rSw0yVb/vlWAYkK/YBwk=",
      ),
    );
    assert_eq!(
      verify_tarball_integrity(
        &package,
        &Vec::new(),
        &NpmPackageVersionDistInfoIntegrity::Integrity {
          algorithm: "sha512",
          base64_hash: "test"
        }
      )
      .unwrap_err()
      .to_string(),
      format!(
        "Tarball checksum did not match what was provided by npm registry for package@1.0.0.\n\nExpected: test\nActual: {actual_checksum}"
      ),
    );
    assert!(
      verify_tarball_integrity(
        &package,
        &Vec::new(),
        &NpmPackageVersionDistInfoIntegrity::Integrity {
          algorithm: "sha512",
          base64_hash: actual_checksum,
        },
      )
      .is_ok()
    );
    let actual_hex = "da39a3ee5e6b4b0d3255bfef95601890afd80709";
    assert_eq!(
      verify_tarball_integrity(
        &package,
        &Vec::new(),
        &NpmPackageVersionDistInfoIntegrity::LegacySha1Hex("test"),
      )
      .unwrap_err()
      .to_string(),
      format!(
        "Tarball checksum did not match what was provided by npm registry for package@1.0.0.\n\nExpected: test\nActual: {actual_hex}"
      ),
    );
    assert!(
      verify_tarball_integrity(
        &package,
        &Vec::new(),
        &NpmPackageVersionDistInfoIntegrity::LegacySha1Hex(actual_hex),
      )
      .is_ok()
    );
  }

  #[test]
  fn rename_with_retries_succeeds_exists() {
    let temp_dir = TempDir::new().unwrap();
    let folder_1 = temp_dir.path().join("folder_1");
    let folder_2 = temp_dir.path().join("folder_2");

    let sys = sys_traits::impls::RealSys;
    sys.fs_create_dir_all(&folder_1).unwrap();
    sys.fs_write(folder_1.join("a.txt"), "test").unwrap();
    sys.fs_create_dir_all(&folder_2).unwrap();
    // this will not end up in the output as rename_with_retries assumes
    // the folders ending up at the destination are the same
    sys.fs_write(folder_2.join("b.txt"), "test2").unwrap();

    let dest_folder = temp_dir.path().join("dest_folder");

    rename_with_retries(&sys, folder_1.as_path(), &dest_folder).unwrap();
    rename_with_retries(&sys, folder_2.as_path(), &dest_folder).unwrap();
    assert!(sys.fs_exists_no_err(dest_folder.join("a.txt")));
    assert!(!sys.fs_exists_no_err(dest_folder.join("b.txt")));
  }
}
