//===----------------------------------------------------------------------===//
// Copyright © 2025-2026 Apple Inc. and the Containerization project authors.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
//   https://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//===----------------------------------------------------------------------===//

/// NOTE: This binary implements a very small subset of the OCI runtime spec, mostly just
/// the process configurations. Mounts are somewhat functional, but masked and read only paths
/// aren't checked today. Today the namespaces are also ignored, and we always spawn a new pid
/// and mount namespace.

import ArgumentParser
import ContainerizationError
import ContainerizationOCI
import ContainerizationOS
import FoundationEssentials
import LCShim
import Logging
import SystemPackage

#if canImport(Musl)
import Musl
#elseif canImport(Glibc)
import Glibc
#endif

@main
struct App: ParsableCommand {
    static let ackPid = "AckPid"
    static let ackConsole = "AckConsole"

    static let configuration = CommandConfiguration(
        commandName: "vmexec",
        version: "0.1.0",
        subcommands: [
            ExecCommand.self,
            RunCommand.self,
        ]
    )
}

extension App {
    /// Applies O_CLOEXEC to all file descriptors currently open for
    /// the process except the stdio fd values
    static func applyCloseExecOnFDs() throws {
        let minFD = 2  // stdin, stdout, stderr should be preserved

        let fdList = try FileManager.default.contentsOfDirectory(atPath: "/proc/self/fd")

        for fdStr in fdList {
            guard let fd = Int(fdStr) else {
                continue
            }
            if fd <= minFD {
                continue
            }

            _ = fcntl(Int32(fd), F_SETFD, FD_CLOEXEC)
        }
    }

    static func exec(process: ContainerizationOCI.Process, currentEnv: [String]? = nil) throws {
        guard !process.args.isEmpty else {
            throw App.Errno(stage: "exec", info: "process args cannot be empty")
        }

        let executableArg = process.args[0]
        let resolvedExecutable: URL

        if executableArg.contains("/") {
            if executableArg.hasPrefix("/") {
                resolvedExecutable = URL(fileURLWithPath: executableArg)
            } else {
                resolvedExecutable = URL(fileURLWithPath: process.cwd).appendingPathComponent(executableArg).standardized
            }

            guard FileManager.default.fileExists(atPath: resolvedExecutable.path) else {
                throw App.Failure(message: "failed to find target executable \(executableArg)")
            }
        } else {
            let path = Path.findPath(currentEnv) ?? Path.getCurrentPath()
            guard let found = Path.lookPath(executableArg, path: path) else {
                throw App.Failure(message: "failed to find target executable \(executableArg)")
            }
            resolvedExecutable = found
        }

        let executable = strdup(resolvedExecutable.path)
        var argv = process.args.map { strdup($0) }
        argv += [nil]
        let env = process.env.map { strdup($0) } + [nil]
        let cwd = process.cwd

        // Create the working directory if it doesn't exist, this seems like the expected
        // OCI runtime spec behavior.
        if !FileManager.default.fileExists(atPath: cwd) {
            try FileManager.default.createDirectory(
                atPath: cwd,
                withIntermediateDirectories: true,
                attributes: [.posixPermissions: 0o755]
            )
        }

        guard chdir(cwd) == 0 else {
            throw App.Errno(stage: "chdir(cwd)", info: "failed to change directory to '\(cwd)'")
        }

        guard execvpe(executable, argv, env) != -1 else {
            throw App.Errno(stage: "execvpe(\(String(describing: executable)))", info: "failed to exec [\(process.args.joined(separator: " "))]")
        }
        fatalError("execvpe failed")
    }

    static func setPermissions(user: ContainerizationOCI.User) throws {
        if user.additionalGids.count > 0 {
            guard setgroups(user.additionalGids.count, user.additionalGids) == 0 else {
                throw App.Errno(stage: "setgroups()")
            }
        }
        guard setgid(user.gid) == 0 else {
            throw App.Errno(stage: "setgid()")
        }
        // NOTE: setuid has to be done last because once the uid has been
        // changed, then the process will lose privilege to set the group
        // and supplementary groups
        guard setuid(user.uid) == 0 else {
            throw App.Errno(stage: "setuid()")
        }
    }

    static func fixStdioPerms(user: ContainerizationOCI.User) throws {
        for i in 0...2 {
            var fdStat = stat()
            try withUnsafeMutablePointer(to: &fdStat) { pointer in
                guard fstat(Int32(i), pointer) == 0 else {
                    throw App.Errno(stage: "fstat(fd)")
                }
            }

            let desired = uid_t(user.uid)
            if fdStat.st_uid != desired {
                guard fchown(Int32(i), desired, fdStat.st_gid) != -1 else {
                    throw App.Errno(stage: "fchown(\(i))")
                }
            }
        }
    }

    static func setRLimits(rlimits: [ContainerizationOCI.POSIXRlimit]) throws {
        for rl in rlimits {
            let resource: Int32
            switch rl.type {
            case "RLIMIT_AS":
                resource = CZ_RLIMIT_AS
            case "RLIMIT_CORE":
                resource = CZ_RLIMIT_CORE
            case "RLIMIT_CPU":
                resource = CZ_RLIMIT_CPU
            case "RLIMIT_DATA":
                resource = CZ_RLIMIT_DATA
            case "RLIMIT_FSIZE":
                resource = CZ_RLIMIT_FSIZE
            case "RLIMIT_LOCKS":
                resource = CZ_RLIMIT_LOCKS
            case "RLIMIT_MEMLOCK":
                resource = CZ_RLIMIT_MEMLOCK
            case "RLIMIT_MSGQUEUE":
                resource = CZ_RLIMIT_MSGQUEUE
            case "RLIMIT_NICE":
                resource = CZ_RLIMIT_NICE
            case "RLIMIT_NOFILE":
                resource = CZ_RLIMIT_NOFILE
            case "RLIMIT_NPROC":
                resource = CZ_RLIMIT_NPROC
            case "RLIMIT_RSS":
                resource = CZ_RLIMIT_RSS
            case "RLIMIT_RTPRIO":
                resource = CZ_RLIMIT_RTPRIO
            case "RLIMIT_RTTIME":
                resource = CZ_RLIMIT_RTTIME
            case "RLIMIT_SIGPENDING":
                resource = CZ_RLIMIT_SIGPENDING
            case "RLIMIT_STACK":
                resource = CZ_RLIMIT_STACK
            default:
                errno = EINVAL
                throw App.Errno(stage: "rlimit key unknown")
            }
            guard CZ_setrlimit(resource, rl.soft, rl.hard) == 0 else {
                throw App.Errno(stage: "setrlimit()")
            }
        }
    }

    static func prepareCapabilities(capabilities: ContainerizationOCI.LinuxCapabilities) throws -> ContainerizationOS.LinuxCapabilities? {
        // Create capabilities instance from OCI config
        var caps = ContainerizationOS.LinuxCapabilities()

        caps.set(which: [.effective], caps: (capabilities.effective ?? []).compactMap { try? CapabilityName(rawValue: $0) })
        caps.set(which: [.permitted], caps: (capabilities.permitted ?? []).compactMap { try? CapabilityName(rawValue: $0) })
        caps.set(which: [.inheritable], caps: (capabilities.inheritable ?? []).compactMap { try? CapabilityName(rawValue: $0) })
        caps.set(which: [.bounding], caps: (capabilities.bounding ?? []).compactMap { try? CapabilityName(rawValue: $0) })
        caps.set(which: [.ambient], caps: (capabilities.ambient ?? []).compactMap { try? CapabilityName(rawValue: $0) })

        // Apply bounding set BEFORE user change (drop capabilities early)
        do {
            try caps.apply(kind: .bounds)
        } catch {
            throw App.Failure(message: "failed to apply bounding set capabilities: \(error)")
        }

        // Set keep caps to preserve capabilities across setuid()
        do {
            try LinuxCapabilities.setKeepCaps()
        } catch {
            throw App.Failure(message: "failed to set keep caps: \(error)")
        }

        return caps
    }

    static func finishCapabilities(_ caps: ContainerizationOS.LinuxCapabilities?) throws {
        guard let caps = caps else { return }

        do {
            try LinuxCapabilities.clearKeepCaps()
        } catch {
            throw App.Failure(message: "failed to clear keep caps: \(error)")
        }

        do {
            try caps.apply(kind: [.caps])
        } catch {
            throw App.Failure(message: "failed to apply final capabilities: \(error)")
        }

        try? caps.apply(kind: [.ambs])
    }

    static func setNoNewPrivileges(process: ContainerizationOCI.Process) throws {
        guard process.noNewPrivileges else { return }
        guard CZ_prctl_set_no_new_privs() == 0 else {
            throw App.Errno(stage: "prctl(PR_SET_NO_NEW_PRIVS)")
        }
    }

    static func Errno(stage: String, info: String = "") -> ContainerizationError {
        let posix = POSIXError(.init(rawValue: errno)!, userInfo: ["stage": stage])
        return ContainerizationError(.internalError, message: "\(info) \(String(describing: posix))")
    }

    static func Failure(message: String) -> ContainerizationError {
        ContainerizationError(
            .internalError,
            message: message
        )
    }

    static func writeError(_ error: Error) {
        let errorPipe = FileDescriptor(rawValue: 5)

        let errorMessage: String
        if let czError = error as? ContainerizationError {
            errorMessage = czError.description
        } else {
            errorMessage = String(describing: error)
        }

        let bytes = Array(errorMessage.utf8)
        _ = try? bytes.withUnsafeBytes { buffer in
            try errorPipe.write(buffer)
        }
        try? errorPipe.close()
    }
}
