mirror of
https://github.com/Lakr233/vphone-cli.git
synced 2026-09-02 02:34:29 +00:00
vphone-cli: Verify and repair libkeystone in the managed venv
The unified tool provisions ~/.vphone/venv itself and never runs setup_venv.sh, so it lost that script's libkeystone handling. It pip-installs and then verifies with pythonIsUsable, which only probes ipsw_parser — a venv with keystone bindings and no native library passes, gets cached as good, and fails much later inside fw patch. That state is reachable and silent. PyPI has no arm64 macOS wheel (only macosx_10_14_x86_64), so pip builds keystone-engine from its sdist, and the sdist's darwin path ignores the build's exit status (subprocess.call plus a glob that matches nothing) — a failed native build still installs bindings alone and pip reports success. Probe that keystone can assemble rather than merely import, and on failure install a loadable dylib next to the bindings: copy one from Homebrew if the bottle ships it, else link one from libkeystone.a, matching setup_venv.sh. The managed venv is repaired in place before being rebuilt, since a missing dylib is not worth a full re-install. Verified: repairs a venv whose dylibs were removed, and recovers a fresh bootstrap that produced bindings-only (pip cache cleared, cmake off PATH) — pip exits 0, the guard catches it, the venv is accepted. Co-Authored-By: Claude Fable 5 <[email protected]>
This commit is contained in:
committed by
zqxwce
co-authored by
Claude Fable 5
parent
0491a9521a
commit
5d22d3ed23
@@ -109,6 +109,71 @@ public struct VPhoneResources: Sendable {
|
|||||||
return (try? VPhoneProcessRunner.runCapturing(python, ["-c", probe]))?.succeeded == true
|
return (try? VPhoneProcessRunner.runCapturing(python, ["-c", probe]))?.succeeded == true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Must assemble, not just import — a bindings-only install imports fine
|
||||||
|
/// and then fails inside `fw patch`.
|
||||||
|
func keystoneIsUsable(_ python: URL) -> Bool {
|
||||||
|
let probe = "from keystone import Ks, KS_ARCH_ARM64, KS_MODE_LITTLE_ENDIAN; import sys; "
|
||||||
|
+ "sys.exit(0 if bytes(Ks(KS_ARCH_ARM64, KS_MODE_LITTLE_ENDIAN).asm('nop')[0]) else 1)"
|
||||||
|
return (try? VPhoneProcessRunner.runCapturing(python, ["-c", probe]))?.succeeded == true
|
||||||
|
}
|
||||||
|
|
||||||
|
func venvIsUsable(_ python: URL) -> Bool {
|
||||||
|
pythonIsUsable(python) && keystoneIsUsable(python)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - keystone native library
|
||||||
|
|
||||||
|
/// Older bottles ship only the static archive.
|
||||||
|
private func homebrewKeystoneLibs() -> (dylib: URL?, archive: URL?) {
|
||||||
|
for prefix in ["/opt/homebrew/opt/keystone/lib", "/usr/local/opt/keystone/lib"] {
|
||||||
|
let dir = URL(fileURLWithPath: prefix)
|
||||||
|
guard let names = try? FileManager.default.contentsOfDirectory(atPath: dir.path) else { continue }
|
||||||
|
let dylib = names.first { $0.hasPrefix("libkeystone") && $0.hasSuffix(".dylib") }
|
||||||
|
let archive = names.first { $0 == "libkeystone.a" }
|
||||||
|
if dylib != nil || archive != nil {
|
||||||
|
return (dylib.map(dir.appendingPathComponent), archive.map(dir.appendingPathComponent))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return (nil, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Asked of the interpreter: `import keystone` is what's broken here.
|
||||||
|
private func keystonePackageDir(_ python: URL) -> URL? {
|
||||||
|
let probe = "import sysconfig; print(sysconfig.get_paths()['purelib'])"
|
||||||
|
guard let r = try? VPhoneProcessRunner.runCapturing(python, ["-c", probe]), r.succeeded else { return nil }
|
||||||
|
let purelib = r.stdout.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
guard !purelib.isEmpty else { return nil }
|
||||||
|
return URL(fileURLWithPath: purelib).appendingPathComponent("keystone")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// PyPI has no arm64 macOS wheel and the sdist ignores its build's exit
|
||||||
|
/// status, so a failed native build still installs bindings alone and pip
|
||||||
|
/// reports success. Same recovery as scripts/setup_venv.sh.
|
||||||
|
func repairKeystone(_ python: URL) -> Bool {
|
||||||
|
guard let pkgDir = keystonePackageDir(python),
|
||||||
|
FileManager.default.fileExists(atPath: pkgDir.path) else { return false }
|
||||||
|
let dest = pkgDir.appendingPathComponent("libkeystone.dylib")
|
||||||
|
let libs = homebrewKeystoneLibs()
|
||||||
|
|
||||||
|
if let dylib = libs.dylib {
|
||||||
|
try? FileManager.default.removeItem(at: dest)
|
||||||
|
guard (try? FileManager.default.copyItem(at: dylib, to: dest)) != nil else { return false }
|
||||||
|
try? FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: dest.path)
|
||||||
|
} else if let archive = libs.archive {
|
||||||
|
let r = try? VPhoneProcessRunner.runCapturing(
|
||||||
|
URL(fileURLWithPath: "/usr/bin/clang"),
|
||||||
|
["-shared", "-o", dest.path, "-Wl,-all_load", archive.path,
|
||||||
|
"-lc++", "-install_name", "@rpath/libkeystone.dylib"])
|
||||||
|
guard r?.succeeded == true else { return false }
|
||||||
|
} else {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
guard keystoneIsUsable(python) else { return false }
|
||||||
|
FileHandle.standardError.write(Data("[+] Repaired keystone native library: \(dest.path)\n".utf8))
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
/// Resolve a python with working deps: an explicit `VPHONE_PYTHON`, the dev
|
/// Resolve a python with working deps: an explicit `VPHONE_PYTHON`, the dev
|
||||||
/// repo `.venv`, the managed per-user venv, else provision the managed venv
|
/// repo `.venv`, the managed per-user venv, else provision the managed venv
|
||||||
/// on this machine. Never silently falls back to a stale system python.
|
/// on this machine. Never silently falls back to a stale system python.
|
||||||
@@ -118,8 +183,13 @@ public struct VPhoneResources: Sendable {
|
|||||||
if pythonIsUsable(u) { return u }
|
if pythonIsUsable(u) { return u }
|
||||||
}
|
}
|
||||||
let devVenv = base.appendingPathComponent(".venv/bin/python3")
|
let devVenv = base.appendingPathComponent(".venv/bin/python3")
|
||||||
if pythonIsUsable(devVenv) { return devVenv }
|
if venvIsUsable(devVenv) { return devVenv }
|
||||||
if pythonIsUsable(managedVenvPython) { return managedVenvPython }
|
// Repair in place before rebuilding — a missing dylib is not worth a
|
||||||
|
// full re-install.
|
||||||
|
if pythonIsUsable(managedVenvPython),
|
||||||
|
keystoneIsUsable(managedVenvPython) || repairKeystone(managedVenvPython) {
|
||||||
|
return managedVenvPython
|
||||||
|
}
|
||||||
return try bootstrapManagedVenv()
|
return try bootstrapManagedVenv()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -156,6 +226,11 @@ public struct VPhoneResources: Sendable {
|
|||||||
guard pythonIsUsable(py) else {
|
guard pythonIsUsable(py) else {
|
||||||
lastError = "venv from \(host.path) still lacks a usable ipsw_parser (too old?)"; continue
|
lastError = "venv from \(host.path) still lacks a usable ipsw_parser (too old?)"; continue
|
||||||
}
|
}
|
||||||
|
guard keystoneIsUsable(py) || repairKeystone(py) else {
|
||||||
|
lastError = "venv from \(host.path) has no working libkeystone — "
|
||||||
|
+ "`brew install keystone`, or install cmake so pip can build it"
|
||||||
|
continue
|
||||||
|
}
|
||||||
log("[+] Python environment ready: \(py.path)")
|
log("[+] Python environment ready: \(py.path)")
|
||||||
return py
|
return py
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -32,6 +32,16 @@ struct ResourcesTests {
|
|||||||
#expect(r.toolsBinDir.path == r.base.appendingPathComponent(".tools/bin").path)
|
#expect(r.toolsBinDir.path == r.base.appendingPathComponent(".tools/bin").path)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// These all shell out; a missing interpreter must return false, not throw.
|
||||||
|
@Test func venvProbesAreTotalForAMissingInterpreter() {
|
||||||
|
let r = VPhoneResources(base: URL(fileURLWithPath: "/x"))
|
||||||
|
let missing = URL(fileURLWithPath: "/nonexistent/bin/python3")
|
||||||
|
#expect(r.pythonIsUsable(missing) == false)
|
||||||
|
#expect(r.keystoneIsUsable(missing) == false)
|
||||||
|
#expect(r.venvIsUsable(missing) == false)
|
||||||
|
#expect(r.repairKeystone(missing) == false)
|
||||||
|
}
|
||||||
|
|
||||||
@Test func managedVenvDefaultsUnderDotVphone() {
|
@Test func managedVenvDefaultsUnderDotVphone() {
|
||||||
// The override env var would change this; only assert the default.
|
// The override env var would change this; only assert the default.
|
||||||
if ProcessInfo.processInfo.environment["VPHONE_VENV_DIR"] != nil { return }
|
if ProcessInfo.processInfo.environment["VPHONE_VENV_DIR"] != nil { return }
|
||||||
|
|||||||
Reference in New Issue
Block a user