#!/usr/bin/env ruby
# frozen_string_literal: true

# Verifies the source-built VLCKit packages that replace CocoaPods' unpatched
# 3.6.0 binaries in Zyryn App Store builds. This is deliberately stricter than
# the archive privacy audit: source identity, architectures, public ABI,
# deployment floor, install name, and the removed API are all fail-closed.

require 'digest'
require 'json'
require 'open3'
require 'rubygems/version'
require 'set'

VLCKIT_COMMIT = 'c73b779f2d1e3d3c1598a27811679ce2c1a005db'
VLC_COMMIT = 'dd8bfdbabe8ae3974ca3864ad3125879f523e3a2'
COPYING_SHA256 = 'dc626520dcd53a22f727af3ee42c770e56c97a64fe3adb063799d8ab032fe551'

POLICIES = {
  'ios' => {
    name: 'MobileVLCKit',
    platform: 'ios',
    deployment_floor: Gem::Version.new('15.0'),
    header_digest: 'f916654747d32cc80b0872a17d73d5abba3c3ea09dad07afdc31d4dc0c497f26',
    # The Xcode 26 source builds preserve every 3.6.0 export and add only
    # four dependency/toolchain exports documented in CUSTOM_VLCKIT.md.
    export_digest: '5980c9952a777be75b341012cdf6db273b0a2b8b4e5361f2c53e3c712c71f81d'
  },
  'tvos' => {
    name: 'TVVLCKit',
    platform: 'tvos',
    deployment_floor: Gem::Version.new('13.0'),
    # Apple introduced the arm64 tvOS simulator at 14.0. The x86_64
    # simulator and arm64 device slices must still support the app's floor.
    simulator_floor_overrides: { 'arm64' => Gem::Version.new('14.0') },
    header_digest: '75ed6337b187e6a3a6c601d541c4e2c818753f570fc4ab4c2a115abede58a022',
    # The Xcode 26 source build has the same four reviewed additions as iOS.
    export_digest: '55a3c775cd6b6715ecd999bf7bf2e83c5785657e3d3b10f9114880de41c1417c'
  }
}.freeze

def abort_verification(message)
  warn "[appstore] custom VLCKit verification failed: #{message}"
  exit 1
end

def capture(*command)
  stdout, stderr, status = Open3.capture3(*command)
  return stdout if status.success?

  abort_verification("command failed: #{command.join(' ')}\n#{stderr.strip}")
end

def aggregate_files(root, directories)
  entries = directories.flat_map do |directory|
    Dir.glob(File.join(root, directory, '**', '*'), File::FNM_DOTMATCH)
  end.select { |path| File.file?(path) }.sort.map do |path|
    relative = path.delete_prefix("#{root}/")
    "#{Digest::SHA256.file(path).hexdigest}  #{relative}\n"
  end
  Digest::SHA256.hexdigest(entries.join)
end

def exported_symbols_digest(binary)
  symbols = capture('/usr/bin/nm', '-gjU', binary).lines.map(&:strip).reject(&:empty?).uniq.sort
  Digest::SHA256.hexdigest(symbols.map { |symbol| "#{symbol}\n" }.join)
end

def verify_minos(binary, architectures, floor, overrides)
  architectures.each do |architecture|
    load_commands = capture('/usr/bin/otool', '-arch', architecture, '-l', binary)
    versions = load_commands.split(/(?=^Load command \d+$)/).map do |command|
      next unless command.match?(/^\s+cmd LC_(?:BUILD_VERSION|VERSION_MIN_[A-Z0-9_]+)$/)

      command[/^\s+(?:minos|version)\s+([0-9]+(?:\.[0-9]+){1,2})$/, 1]
    end.compact
    abort_verification("no Mach-O minimum OS command for #{architecture} in #{binary}") if versions.empty?

    allowed_floor = overrides.fetch(architecture, floor)
    versions.each do |version|
      next if Gem::Version.new(version) <= allowed_floor

      abort_verification(
        "#{binary} #{architecture} requires OS #{version}, above allowed floor #{allowed_floor}"
      )
    end
  end
end

platform = ARGV.fetch(0) { abort_verification('usage: verify_custom_vlckit.rb <ios|tvos> <package-root>') }
package_root = File.expand_path(ARGV.fetch(1) { abort_verification('package root is required') })
policy = POLICIES.fetch(platform) { abort_verification("unsupported platform: #{platform}") }
name = policy.fetch(:name)
xcframework = File.join(package_root, "#{name}.xcframework")
info_plist = File.join(xcframework, 'Info.plist')
copying = File.join(package_root, 'COPYING.txt')
provenance_path = File.join(package_root, 'provenance.json')

abort_verification("XCFramework not found: #{xcframework}") unless File.directory?(xcframework)
abort_verification("LGPL text not found: #{copying}") unless File.file?(copying)
abort_verification("provenance not found: #{provenance_path}") unless File.file?(provenance_path)
actual_copying_sha = Digest::SHA256.file(copying).hexdigest
unless actual_copying_sha == COPYING_SHA256
  abort_verification("LGPL text digest #{actual_copying_sha}, expected #{COPYING_SHA256}")
end

provenance = JSON.parse(File.read(provenance_path))
expected_provenance = {
  'vlckit_commit' => VLCKIT_COMMIT,
  'vlc_commit' => VLC_COMMIT,
  'patch' => 'scripts/appstore/patches/vlckit-3.6.0-no-fstatfs.patch',
  'patch_sha256' => 'fb5c003923c20129b6210b63b942d8bcaa6a1fc8406169e1201676c42442393b',
  'build_patch' => 'scripts/appstore/patches/vlckit-3.6.0-xcode26.patch',
  'build_patch_sha256' => '2617b97a024d445c614cfc8546db3f5996cb9df6c5116418d8819efd42e93eda',
  'vlckit_build_patch' => 'scripts/appstore/patches/vlckit-3.6.0-build-script.patch',
  'vlckit_build_patch_sha256' => '9e5a814b0ef70994c84f305cc3117d26f328e5ae1b96e9b301e006e9eb9233f7',
  'platform' => platform,
  'license_notice' => 'https://zyryn.com/open-source'
}
expected_provenance.each do |key, expected|
  actual = provenance[key]
  abort_verification("provenance #{key}=#{actual.inspect}, expected #{expected}") unless actual == expected
end

libraries = JSON.parse(capture('/usr/bin/plutil', '-convert', 'json', '-o', '-', info_plist))
                .fetch('AvailableLibraries')
expected_variants = {
  nil => ['arm64'],
  'simulator' => %w[arm64 x86_64]
}
actual_variants = libraries.to_h do |library|
  [library['SupportedPlatformVariant'], Array(library.fetch('SupportedArchitectures')).sort]
end
abort_verification("unexpected XCFramework slices: #{actual_variants}") unless actual_variants == expected_variants

device_framework = nil
libraries.each do |library|
  supported_platform = library.fetch('SupportedPlatform')
  abort_verification("unexpected supported platform: #{supported_platform}") unless supported_platform == policy.fetch(:platform)

  framework = File.join(xcframework, library.fetch('LibraryIdentifier'), library.fetch('LibraryPath'))
  binary = File.join(framework, name)
  abort_verification("framework binary not found: #{binary}") unless File.file?(binary)
  device_framework = framework unless library.key?('SupportedPlatformVariant')

  actual_archs = capture('/usr/bin/lipo', '-archs', binary).split.sort
  expected_archs = Array(library.fetch('SupportedArchitectures')).sort
  abort_verification("#{binary} architectures #{actual_archs}, expected #{expected_archs}") unless actual_archs == expected_archs

  install_names = capture('/usr/bin/otool', '-D', binary).lines.map(&:strip).reject(&:empty?)
  expected_install_name = "@rpath/#{name}.framework/#{name}"
  abort_verification("#{binary} install name is not #{expected_install_name}") unless install_names.include?(expected_install_name)

  undefined = capture('/usr/bin/nm', '-u', binary)
  forbidden = undefined.scan(/_(?:f?stat(?:fs|vfs))\b/).to_set
  abort_verification("#{binary} imports #{forbidden.to_a.sort.join(', ')}") unless forbidden.empty?

  exports = exported_symbols_digest(binary)
  expected_exports = policy.fetch(:export_digest)
  abort_verification("#{binary} exported ABI digest #{exports}, expected #{expected_exports}") unless exports == expected_exports
  simulator_overrides = if library['SupportedPlatformVariant'] == 'simulator'
                          policy.fetch(:simulator_floor_overrides, {})
                        else
                          {}
                        end
  verify_minos(binary, actual_archs, policy.fetch(:deployment_floor), simulator_overrides)
end

abort_verification('device framework was not found') unless device_framework
headers = aggregate_files(device_framework, %w[Headers PrivateHeaders Modules])
expected_headers = policy.fetch(:header_digest)
abort_verification("header ABI digest #{headers}, expected #{expected_headers}") unless headers == expected_headers

puts "[appstore] verified custom #{name} source identity, LGPL text, slices, deployment targets, headers, exports, install names, and privacy API removal"
