一个 .hvmz 目录自包含一台 VM 的全部持久化状态, 可整个拷贝迁移。设计要点:
- 目录而非单文件, 方便编辑 / diff / 增量备份
- 与 hell-vm 的
.hellvm严格隔离, 扩展名不同, 内部结构也不同 - 同一时刻仅允许一个进程打开(fcntl flock 互斥), 避免两处并发改 config 或写磁盘
- ISO 只存绝对路径, 不复制进 bundle(避免重复占用几 GB)
- config 带
schemaVersion, 跨版本走ConfigMigrator链式升级
foo.hvmz/
├── config.yaml — VMConfig 的 YAML 序列化(权威配置, schema v3)
├── auxiliary/ — VZ macOS guest 必需数据
│ ├── aux-storage — VZMacAuxiliaryStorage 后端文件
│ ├── machine-identifier — VZMacMachineIdentifier (Data)
│ └── hardware-model — VZMacHardwareModel (Data, 一次性生成不可变)
├── disks/ — 所有磁盘镜像
│ ├── os.img — VZ 后端主盘 (raw sparse)
│ ├── os.qcow2 — QEMU 后端主盘 (qcow2)
│ └── data-<uuid8>.{img|qcow2} — 可选数据盘, 同 engine 的格式跟随
├── nvram/ — Linux/Windows guest EFI 变量
│ └── efi-vars.fd — VZEFIVariableStore / QEMU OVMF VARS
├── tpm/ — Win11 swtpm 持久化 (NVRAM 表征)
├── snapshots/ — 快照子目录
├── logs/ — guest 内部产生的日志(唯一允许的写入来源是
│ ConsoleBridge / QemuConsoleBridge)
│ └── console-<date>.log
├── unattend.iso — Win 装机 AutoUnattend.xml 打的 ISO (运行时生成)
├── .unattend-stage/ — unattend ISO 的 staging
├── .lock — flock 占用文件 + 持有者 JSON
├── meta/ — 非关键元数据
│ └── thumbnail.png — GUI 列表缩略图
└── (无 run/ 子目录, 运行时 socket 走全局 HVMPaths.runDir/<uuid>.*)
按 EncryptionSpec.scheme 分两路 (详见 ENCRYPTION.md):
scheme = qemu-perfile (QEMU 后端, 当前主路径):
foo.hvmz/
├── config.yaml.enc — config.yaml 的 AES-256-GCM 密文 ("HENC" magic + nonce + cipher + tag)
├── meta/
│ ├── encryption.json — routing JSON, **明文**, 跨机器派生 KEK 入口
│ │ (schemaVersion / vm_id / scheme / kdf_algo / kdf_iterations / kdf_salt /
│ │ kdf_keylen / encrypted_paths / display_name / guest_os)
│ └── thumbnail.png
├── disks/
│ ├── os.qcow2 — qcow2 LUKS-aes-256-xts (qcow2 内嵌 LUKS header)
│ └── data-<uuid8>.qcow2 — 同 LUKS qcow2
├── nvram/
│ └── efi-vars.qcow2 — Win VM: OVMF VARS 转 LUKS qcow2 (raw 模板 → qemu-img convert 加密)
├── tpm/ — swtpm encrypt: aes-256-cbc + format=binary, 由 swtpm key fd 注入
└── (其余目录与明文一致, 但 disks/nvram/tpm 落盘内容均为密文)
scheme = vz-sparsebundle (VZ 后端, 暂只接通"创建"分支, 启动解锁推后):
<parent>/
├── foo.hvmz.sparsebundle/ — hdiutil AES-256 + APFS 整体加密容器
│ └── (attach 后 mountpoint=/Volumes/HVM-<uuid8>)
│ └── foo.hvmz/
│ ├── config.yaml — 明文 (sparsebundle 自己加密)
│ └── [其余文件结构同明文 VM]
└── foo.hvmz.encryption.json — routing JSON, **明文**, 落 sparsebundle 外部
注:
auxiliary/仅guestOS=.macOS用, 丢失整台 VM 报废nvram/Linux/Windows 用; Windows 还配tpm/unattend.iso与.unattend-stage/仅 Windows guest 装机阶段- 运行时 socket(IPC / QMP / HDP / vdagent / swtpm)走
~/Library/Application Support/HVM/run/<uuid>.*, 不在 bundle 内 - VM 删除时不清理 host 侧
~/Library/Application Support/HVM/logs/<displayName>-<uuid8>/ - 加密 VM
meta/encryption.json故意落明文: 没它 master KEK 派生不出来; 用户备份加密 VM 必须把 routing JSON 一起带
v1 (.json) 已断兼容:
BundleIO.load检测到config.json存在但无config.yaml时直接报错,要求重新创建或手动迁移。 v2 (.yaml, 早期无加密字段) 仍可读, 走ConfigMigrator.migrate_v2_to_v3自动升级 (顶层加encryption: { enabled: false }兜底)。
| 字段 | 类型 | 说明 | 克隆 |
|---|---|---|---|
schemaVersion |
Int | 当前 3。> 当前抛 .invalidSchema; < 当前走 ConfigMigrator |
保留 |
id |
UUID | bundle 创建时生成, 跟随一生 | 重生 |
createdAt |
ISO8601 Date | 创建时间, 仅展示 | 重生 |
displayName |
String | 展示名, 允许中文, 可与目录名不一致 | 重生 (用户输入) |
guestOS |
macOS | linux | windows |
其他值拒绝 | 保留 |
engine |
vz | qemu |
缺省 .vz(老 v1 兼容兜底), validate() 校验合法组合 |
保留 |
cpuCount |
Int | VZ 后端范围由 VZ API 限定 | 保留 |
memoryMiB |
UInt64 | MiB | 保留 |
disks |
[DiskSpec] | 至少一项, 第一项 role=main |
内容 cloneFile; 数据盘 data-<uuid8>.* 文件名重生 + path 同步 |
networks |
[NetworkSpec] | 可空 | macAddress 默认重生 (--keep-mac 保留) |
installerISO |
String? | 绝对路径, 不复制进 bundle | 保留 (绝对路径, 跨 VM 共用 ISO) |
bootFromDiskOnly |
Bool | 装机完成后置 true | 保留 |
windowsDriversInstalled |
Bool | Windows 三态切换(false=ramfb / true=hvm-gpu-ramfb-pci) | 保留 |
clipboardSharingEnabled |
Bool | 默认 true; 仅 QEMU 走 vdagent, VZ macOS guest 自带忽略此字段 | 保留 |
macStyleShortcuts |
Bool | 默认 true; 仅 QEMU 生效, host cmd→guest ctrl | 保留 |
displaySpec |
DisplaySpec? | 显式 framebuffer 尺寸 + DPI; 缺省按 guestOS 走默认 |
保留 |
macOS |
MacOSSpec? | 仅 guestOS=macOS |
保留 |
linux |
LinuxSpec? | 仅 guestOS=linux |
保留 |
windows |
WindowsSpec? | 仅 guestOS=windows |
保留 |
encryption |
EncryptionSpec? | schema v3 加, 详下 | 保留 (clone 走"等价复制 + 同密码", 见 CLONE.md) |
克隆细节见 STORAGE.md "Clone: 整 VM 克隆".
auxiliary/machine-identifier(macOS) 重生;auxiliary/hardware-model/aux-storage/nvram/efi-vars.fd/tpm/*全保留.
VMConfig.validate() 强制:
| guestOS | 允许的 engine |
|---|---|
macOS |
只能 vz (VZMacOSInstaller 路径) |
linux |
vz 或 qemu |
windows |
只能 qemu (VZ 无 TPM) |
disks:
- role: main # main 必须有且只有一个; 其余 data
path: disks/os.qcow2 # 相对 bundle root
sizeGiB: 64
format: qcow2 # raw / qcow2
readOnly: falseformat跟engine走: VZ →raw, QEMU →qcow2(VZ 强约束 raw, qcow2 拒绝)- 运行时严格读
DiskSpec.format, 禁止靠path扩展名推断 format缺字段时按path扩展名兜底(.qcow2→ qcow2, 其他 → raw), 仅给手工编辑的老 yaml 容错path必须落在disks/下, 不能跳出 sandbox(BundleLayout.isDiskPathInSandbox)- 老 QEMU VM 是 raw
.img(从老版本带过来) 仍可继续运行, 不强制迁移
networks:
- mode: vmnetBridged # user / vmnetShared / vmnetHost / vmnetBridged / none
macAddress: "52:54:00:a1:b2:c3"
socketVmnetPath: null # 留空走 SocketPaths 标准路径
bridgedInterface: en0 # 仅 vmnetBridged
deviceModel: virtio # virtio / e1000e / rtl8139
enabled: true- 老枚举名兼容:
nat→user / shared→vmnetShared / bridged→vmnetBridged(由NetworkSpec.init(from:)拦下) - vmnet* 模式靠
socket_vmnet(brew 装), 协议固定路径见 NETWORK.md enabled=false时启动不挂, 运行中可 QMP 热插拔
macOS:
ipsw: /path/to/UniversalMac_*.ipsw
autoInstalled: truelinux:
kernelCmdLineExtra: null
rosettaShare: false # 当前未接 ConfigBuilder, 见 ROADMAP.md "残余项指引" L-2windows:
secureBoot: true
tpmEnabled: true
bypassInstallChecks: true # WindowsUnattend 注入 LabConfig\Bypass*Check
autoInstallVirtioWin: false # 当前 false: UTM Guest Tools ISO 已替代
autoInstallSpiceTools: true # 装完静默装 spice-guest-tools.exeencryption:
enabled: true
scheme: qemu-perfile # qemu-perfile (QEMU 主路径) | vz-sparsebundle (VZ 路径)
createdAt: 2026-04-12T10:23:18Z字段语义:
enabled = false或字段缺省 → 明文 VM, scheme 字段忽略scheme = qemu-perfile→ bundle 内config.yaml不存在, 改为config.yaml.enc(AES-GCM 加密) +meta/encryption.json(明文 routing JSON)scheme = vz-sparsebundle→ bundle 整体套加密 sparsebundle, 解锁后config.yaml仍是明文; routing JSON 落<bundle>.encryption.json(sparsebundle 外部)createdAt仅展示, GUI 详情页用; 改密 (rekey) 不重置该字段
KDF 参数 (salt / iterations / algo) 不在此结构里, 落 routing JSON。原因: scheme=qemu-perfile 时
config.yaml.enc是密文, 解开它需要先派生 master KEK, 而派生需要 KDF 参数 — 把 KDF 放在被加密的 config 里会陷死循环。详见 ENCRYPTION.md。
- Bundle 目录: 任意名 +
.hvmz扩展名。GUI 默认用displayName小写 +-替换空格 - 主盘:
disks/os.img(VZ) 或disks/os.qcow2(QEMU), 由BundleLayout.mainDiskFileName(for:)在创建时生成, 写入DiskSpec.path持久化 - 数据盘:
disks/data-<uuid8>.{img|qcow2}, 同 engine 跟随 - ISO: 不进 bundle, 只记
installerISO绝对路径 - 缩略图:
meta/thumbnail.png(ThumbnailWriteratomic 写, VZ + QEMU 共用)
运行时禁止从常量推断主盘路径:
VMConfig.mainDiskRelPath/VMConfig.mainDiskURL(in:)是唯一入口。BundleLayout.mainDiskFileName(for:)仅创建时调一次, 其余位置不得调用。老 APImainDiskName/mainDiskURL(_ bundle)已删。
仅保留与 VM 无关的结构常量 + 创建时一次性文件名生成器:
// 文件 / 目录名
configFileName / legacyConfigFileName / lockFileName
disksDirName / auxiliaryDirName / nvramDirName / logsDirName / metaDirName / snapshotsDirName
nvramFileName / auxStorageName / machineIdentifier / hardwareModel / thumbnailName
// 路径助手
configURL(_:) / legacyConfigURL(_:) / lockURL(_:)
disksDir(_:) / auxiliaryDir(_:) / nvramDir(_:) / nvramURL(_:)
logsDir(_:) / metaDir(_:) / snapshotsDir(_:) / snapshotDir(_:name:)
serialSocketURL(_:) / tpmStateDir(_:) / unattendISOURL(_:) / unattendStageDir(_:)
// 创建时一次性生成器 (运行时不调)
mainDiskFileName(for engine: Engine) -> String
dataDiskFileName(uuid8:engine:) -> String
// sandbox 校验
isDiskPathInSandbox(_:) -> Bool- VZ 不允许两个
VZVirtualMachine同时使用同一份磁盘文件 - QEMU 同样不允许多进程写 qcow2
- config.yaml 并发修改会竞争, macOS auxiliary 数据并发写更危险
public final class BundleLock {
public enum Mode: String { case runtime, edit }
public init(bundleURL: URL, mode: Mode, socketPath: String = "") throws
// 1. open(bundle/.lock, O_RDWR | O_CREAT, 0644)
// 2. flock(fd, LOCK_EX | LOCK_NB)
// EWOULDBLOCK → throw .busy(pid:, holderMode:)
// 3. ftruncate + 写入 HolderInfo JSON (pid/host/socketPath/mode/since)
public func release()
deinit { release() }
public static func isBusy(bundleURL: URL) -> Bool // 无副作用探测, hvm-cli list 用
public static func inspect(bundleURL: URL) -> HolderInfo?
}- flock 内核持有, 进程退出自动释放, 不留死锁
.lock文件里的 PID 可能过期, 新进程抢锁时覆盖即可- GUI 列表刷新时,
isBusy=false但.lock存在 = stopped(.lock 文件本身不删)
- flock(2) 只在本机 inode 上互斥
- bundle 落 NFS/SMB/exFAT 卷上, 两台主机可同时拿到锁 → 破坏 disks/auxiliary
BundleLockinit 时statfs探测, 非本地 (apfs/hfs) 卷给一次 warn(进程级 dedup), 不强禁
- 扩展名不同:
.hvmzvs.hellvm - 锁文件名
.lock与 hell-vm 内部命名无关 - 两套同时跑不同 bundle 完全安全
- 新字段只加不改, 加字段必须带默认值; Codable
init(from:)用decodeIfPresent兜底 - 删字段保留 yaml key, 读取时忽略
- 不兼容变更(改字段语义/重命名/单位变化)必须升
schemaVersion+ 加迁移 hook - 链式升级
v_n → v_n+1 → ... → current, 不允许跨版本跳
VMConfig.currentSchemaVersion = 3ConfigMigrator.migrate(data:from:to:)链式 hook 框架, 已实现migrate_v2_to_v3: 顶层加encryption: { enabled: false }兜底, schemaVersion 改 3, 幂等- v1 (.json) 已断兼容:
BundleIO.load检测到legacyConfigFileName存在但无configFileName时, 抛BundleError报"重新创建 VM 或手动迁移", 不进入 migrator
- 没有
config.yaml但有config.json→ 报错(老 schema 已断兼容) - 用
_SchemaEnvelope只解schemaVersion字段 > currentSchemaVersion→ 抛.invalidSchema(让用户升 HVM)< currentSchemaVersion→ 走ConfigMigrator.migrate升到当前== currentSchemaVersion→ 直接Yams.YAMLDecoder().decode(VMConfig.self, ...)- 升级后
BundleIO.save以currentSchemaVersion重写 yaml, 下次 load 不再走 migrator
// 1. VMConfig.currentSchemaVersion +1 (例 3 → 4)
// 2. ConfigMigrator.migrate 的 switch 加 case
// case (3, 4): current = try migrate_v3_to_v4(current)
// 3. 实现 migrate_v3_to_v4(_:Data) -> Data:
// Yams.load → [String: Any] dict 改 → Yams.dump → Data
// 最后写入 dict["schemaVersion"] = 4
// 必须幂等: migrate(migrate(x)) ≡ migrate(x)参考实现: ConfigMigrator.migrate_v2_to_v3 (schema v2 → v3 加 encryption: { enabled: false } 兜底)。
config.yaml 写入走 "tmp + atomic rename":
let tmp = bundleURL.appendingPathComponent("config.yaml.tmp")
try data.write(to: tmp, options: .atomic)
try FileManager.default.replaceItem(at: configURL, withItemAt: tmp, ...)避免半写入的 yaml 导致下次加载崩溃。
加载 bundle 时必做校验:
config.yaml存在且可被 Yams 解析为VMConfigschemaVersion <= currentSchemaVersionguestOS/engine合法枚举值,validate()通过- 主盘文件存在
- macOS guest 必须有
auxiliary/hardware-model+machine-identifier, 否则识别为"未完成创建" - 所有
DiskSpec.path通过BundleLayout.isDiskPathInSandbox(disks/ 下且无..)
失败抛 HVMError.bundle(.invalid(reason:)), GUI 用 ErrorDialog 展示。
- GUI 删除 = 移入废纸篓(
NSWorkspace.recycle) - CLI
hvm-cli delete <bundle>: 默认废纸篓;--purge+--force才直接rm -rf - 删除前必须确认
.lock未被持有 - host 侧
~/Library/Application Support/HVM/logs/<displayName>-<uuid8>/不自动清理
- 不签名 bundle(用户改 yaml 自负)
- 不嵌入 snapshot 进 config(独立
snapshots/子目录, qcow2 内部链 + VZ save-state) - 不做 iCloud Drive 同步指引(同步工具与 flock / sparse 文件冲突, 明确不支持)
- 不再支持 v1 .json(2026 年初已断兼容)
加密 bundle 在 schema v3 已落地 (qemu-perfile + vz-sparsebundle 双 scheme), 不再依赖 FileVault 兜底。详见 ENCRYPTION.md。
| 编号 | 问题 | 默认方案 | 决策时机 |
|---|---|---|---|
| B1 | 是否支持"模板 bundle" | 不做, 用 clonefile 手动克隆 | 已决 |
| B3 | 是否引入 config.lock edit 模式 |
字段保留, 暂不强制使用 | M2 |
最后更新: 2026-05-05