1+ import Foundation
12import System
23import AppleArchive
34
@@ -10,8 +11,14 @@ fileprivate let permissions = FilePermissions(rawValue: 0o644)
1011// [2]: https://developer.apple.com/documentation/compression/algorithm/lzfse
1112extension VMDirectory {
1213 func exportToArchive( path: String ) throws {
13- guard !isStackedVM && !isStackedCachedImage else {
14- throw RuntimeError . ExportFailed ( " exporting stacked VMs is not supported yet " )
14+ let temporaryArchive = try stackedArchiveDirectoryIfNeeded ( )
15+ let archiveSourceURL = temporaryArchive? . vmDirectory. baseURL ?? baseURL
16+
17+ defer {
18+ if let temporaryArchive {
19+ try ? temporaryArchive. lock. unlock ( )
20+ try ? temporaryArchive. vmDirectory. removeFromDisk ( )
21+ }
1522 }
1623
1724 guard let fileStream = ArchiveByteStream . fileStream (
@@ -53,7 +60,7 @@ extension VMDirectory {
5360 return
5461 }
5562
56- try encodeStream. writeDirectoryContents ( archiveFrom: FilePath ( baseURL . path) , keySet: keySet)
63+ try encodeStream. writeDirectoryContents ( archiveFrom: FilePath ( archiveSourceURL . path) , keySet: keySet)
5764 }
5865
5966 func importFromArchive( path: String ) throws {
@@ -96,5 +103,145 @@ extension VMDirectory {
96103 }
97104
98105 _ = try ArchiveStream . process ( readingFrom: decodeStream, writingTo: extractStream)
106+
107+ if isStackedVM {
108+ try restoreStackedArchive ( )
109+ }
110+ }
111+
112+ /// Builds a self-contained staging directory for a stacked archive, if this
113+ /// directory currently resolves to a stacked VM or cached image.
114+ private func stackedArchiveDirectoryIfNeeded( ) throws -> ( vmDirectory: VMDirectory , lock: FileLock ) ? {
115+ guard isStackedVM || isStackedCachedImage else {
116+ return nil
117+ }
118+ try DiskImageStack . requireSupport ( )
119+
120+ let contentStore = try ContentStore ( )
121+ let archiveVMDir = try VMDirectory . temporary ( )
122+ let archiveVMDirLock = try FileLock ( lockURL: archiveVMDir. baseURL)
123+ try archiveVMDirLock. lock ( )
124+
125+ do {
126+ let stagedSource : ( isStackedVM: Bool , contentDigests: [ String ] ) ? = try contentStore. withPruneLock {
127+ ( ) -> ( isStackedVM: Bool , contentDigests: [ String ] ) ? in
128+ // OCI tags are mutable symlinks. Resolve one digest record while tag
129+ // replacement and cached-image deletion are blocked, then copy every
130+ // source-owned file before releasing the lock.
131+ let sourceVMDir = VMDirectory ( baseURL: baseURL. resolvingSymlinksInPath ( ) )
132+ guard sourceVMDir. isStackedVM || sourceVMDir. isStackedCachedImage else {
133+ return nil
134+ }
135+
136+ let sourceIsStackedVM = sourceVMDir. isStackedVM
137+ let sourceVMLock : PIDLock ?
138+ if sourceIsStackedVM {
139+ let lock = try sourceVMDir. lock ( )
140+ guard try lock. trylock ( ) else {
141+ throw RuntimeError . ExportFailed ( " VM \" \( sourceVMDir. name) \" must be stopped before export " )
142+ }
143+ sourceVMLock = lock
144+
145+ // Holding the PID lock proves that the VM is not running. A saved
146+ // state file is the remaining suspended state that must reject export.
147+ guard !FileManager. default. fileExists ( atPath: sourceVMDir. stateURL. path) else {
148+ try ? lock. unlock ( )
149+ throw RuntimeError . ExportFailed ( " VM \" \( sourceVMDir. name) \" must be stopped before export " )
150+ }
151+ } else {
152+ sourceVMLock = nil
153+ }
154+ defer { try ? sourceVMLock? . unlock ( ) }
155+
156+ try FileManager . default. copyItem ( at: sourceVMDir. configURL, to: archiveVMDir. configURL)
157+ try FileManager . default. copyItem ( at: sourceVMDir. nvramURL, to: archiveVMDir. nvramURL)
158+ try FileManager . default. copyItem ( at: sourceVMDir. manifestURL, to: archiveVMDir. manifestURL)
159+ if sourceIsStackedVM {
160+ try FileManager . default. copyItem ( at: sourceVMDir. overlayURL, to: archiveVMDir. overlayURL)
161+ }
162+
163+ return ( sourceIsStackedVM, try archiveVMDir. diskContentDigests ( ) )
164+ }
165+
166+ guard let stagedSource else {
167+ try archiveVMDirLock. unlock ( )
168+ try archiveVMDir. removeFromDisk ( )
169+ return nil
170+ }
171+
172+ if !stagedSource. isStackedVM {
173+ try archiveVMDir. diskImageStack ( ) . createWritableOverlay ( )
174+ }
175+
176+ // The staged manifest is now an in-progress reference, so immutable
177+ // content remains protected while these potentially large copies run
178+ // without holding the global prune lock.
179+ for contentDigest in stagedSource. contentDigests {
180+ guard let sourceURL = try contentStore. existingContentURL ( for: contentDigest) else {
181+ throw RuntimeError . ExportFailed ( " VM is missing cached disk content \( contentDigest) " )
182+ }
183+
184+ let destinationURL = try contentStore. contentURL (
185+ for: contentDigest,
186+ under: archiveContentStoreURL ( in: archiveVMDir)
187+ )
188+ try FileManager . default. createDirectory (
189+ at: destinationURL. deletingLastPathComponent ( ) ,
190+ withIntermediateDirectories: true
191+ )
192+ try FileManager . default. copyItem ( at: sourceURL, to: destinationURL)
193+ }
194+
195+ return ( archiveVMDir, archiveVMDirLock)
196+ } catch {
197+ try ? archiveVMDirLock. unlock ( )
198+ try ? archiveVMDir. removeFromDisk ( )
199+ throw error
200+ }
201+ }
202+
203+ /// Restores immutable files from an archive into the shared content store,
204+ /// removes the archive-only payload, then validates the resulting stack.
205+ private func restoreStackedArchive( ) throws {
206+ try DiskImageStack . requireSupport ( )
207+
208+ let contentStore = try ContentStore ( )
209+ // The extracted manifest is already a reference; synchronize publication
210+ // with a concurrent prune before installing its immutable content.
211+ try contentStore. synchronizePublishedReferences ( )
212+ for contentDigest in try diskContentDigests ( ) {
213+ if try contentStore. existingContentURL ( for: contentDigest) != nil {
214+ continue
215+ }
216+
217+ let archivedContentURL = try contentStore. contentURL (
218+ for: contentDigest,
219+ under: archiveContentStoreURL ( in: self )
220+ )
221+ guard FileManager . default. fileExists ( atPath: archivedContentURL. path) else {
222+ throw RuntimeError . ImportFailed ( " archive is missing disk content \( contentDigest) " )
223+ }
224+
225+ let temporaryURL = try contentStore. temporaryContentURL ( for: contentDigest)
226+ do {
227+ try FileManager . default. copyItem ( at: archivedContentURL, to: temporaryURL)
228+ _ = try contentStore. install ( temporaryURL, contentDigest: contentDigest)
229+ } catch {
230+ try ? FileManager . default. removeItem ( at: temporaryURL)
231+ throw error
232+ }
233+ }
234+
235+ try FileManager . default. removeItem ( at: archiveContentStoreURL ( in: self ) )
236+ try ? FileManager . default. removeItem ( at: stateURL)
237+
238+ // Opening the attachment validates the reconstructed immutable stack and
239+ // imported writable overlay before the VM enters local storage.
240+ _ = try diskImageStack ( ) . makeAttachment ( )
99241 }
242+
243+ private func archiveContentStoreURL( in vmDir: VMDirectory ) -> URL {
244+ vmDir. baseURL. appendingPathComponent ( " content " , isDirectory: true )
245+ }
246+
100247}
0 commit comments