1# coding=utf-8
2#
3# test_exfat_userfs.py -- Test cases for the ExFAT plug-in for UserFS.
4#
5# Copyright (c) 2013 Apple Inc.  All rights reserved.
6#
7import sys
8import os
9import io
10import subprocess
11import shutil
12import struct
13import difflib
14import random
15import hashlib
16import unittest
17import msdosfs
18from msdosfs import ATTR_READ_ONLY, ATTR_HIDDEN, ATTR_SYSTEM, ATTR_ARCHIVE, make_long_dirent, Timestamp
19
20class UserFSTestCase(unittest.TestCase):
21    imagePath = '/tmp/FAT_TestCase.sparsebundle'
22    imageSize = '1g'
23    device = None
24
25    @classmethod
26    def runProcess(klass, args, expectedReturnCode=None, expectedStdout=None, expectedStderr=None, env=None):
27        if VERBOSE:
28            print "->", " ".join(args)
29        p = subprocess.Popen(args, stdout=subprocess.PIPE, stderr=subprocess.PIPE, env=env)
30        stdout, stderr = p.communicate()
31        returncode = p.returncode
32        if VERBOSE:
33            sys.stdout.write(stdout)
34            sys.stdout.write(stderr)
35            if returncode:
36                print "## Process exited with return code:", returncode
37        if expectedStderr is not None:
38            if stderr != expectedStderr:
39                diffs = '\n'.join(difflib.unified_diff(expectedStderr.splitlines(), stderr.splitlines(), 'Expected', 'Actual', lineterm=''))
40                assert stderr == expectedStderr, 'Standard error mismatch:\n{0}'.format(diffs)
41        if expectedStdout is not None:
42            if stdout != expectedStdout:
43                diffs = '\n'.join(difflib.unified_diff(expectedStdout.splitlines(), stdout.splitlines(), 'Expected', 'Actual', lineterm=''))
44                assert stdout == expectedStdout, 'Standard output mismatch:\n{0}'.format(diffs)
45        if expectedReturnCode is not None:
46            assert returncode == expectedReturnCode, "Exit status was {0} (expected {1})".format(returncode, expectedReturnCode)
47        return returncode, stdout, stderr
48
49    @classmethod
50    def createDiskImage(klass, size=None):
51        'Create the disk image, if it does not exist yet'
52        if not os.path.exists(klass.imagePath):
53            args = "/usr/bin/hdiutil create -size <SIZE> -layout MBRSPUD -partitionType DOS_FAT_32".split()
54            args[3] = size if size else klass.imageSize
55            args.append(klass.imagePath)
56            klass.runProcess(args, 0, None, "")
57
58    @classmethod
59    def attachDiskImage(klass, readOnly=False):
60        if not klass.device:
61            args='hdiutil attach -nomount'.split()
62            if readOnly:
63                args.append('-readonly')
64            args.append(klass.imagePath)
65            try:
66                returncode, stdout, stderr = klass.runProcess(args, 0, None, "")
67                stdout = stdout.splitlines()
68                assert(len(stdout) == 2)
69                klass.wholeDisk = stdout[0].split()[0]
70                klass.partition = stdout[1].split()[0]
71                assert(klass.partition.startswith("/dev/"))
72                klass.device = klass.partition[5:]
73            except:
74                if os.path.isdir(klass.imagePath):
75                    shutil.rmtree(klass.imagePath)
76                else:
77                    os.remove(klass.imagePath)
78                raise
79
80    @classmethod
81    def ejectDiskImage(klass, keep=None):
82        if keep is None:
83            keep = KEEP_IMAGE
84        if keep:
85            print '#', klass.device, 'is still attached.  To clean up:'
86            print 'diskutil eject', klass.wholeDisk
87        else:
88            args = 'diskutil eject'.split() + [klass.wholeDisk]
89            klass.runProcess(args, 0, None, '')
90            klass.device = None
91
92    @classmethod
93    def deleteDiskImage(klass):
94        if KEEP_IMAGE:
95            print "rm -r", klass.imagePath
96        else:
97            if VERBOSE:
98                print "-> Deleting %s" % klass.imagePath
99            if os.path.isdir(klass.imagePath):
100                shutil.rmtree(klass.imagePath)
101            else:
102                os.remove(klass.imagePath)
103
104    def runTool(self, args, expectedReturnCode=None, expectedStdout=None, expectedStderr=None):
105        # Add the default tool path and device path to the args
106        args = [TOOL_PATH, "--device", self.device] + args
107
108        return self.runProcess(args, expectedReturnCode, expectedStdout, expectedStderr, TOOL_ENV)
109
110    # Formatter=exfat.Formatter
111
112    # Use newfs_msdos to create an empty volume
113    @classmethod
114    def formatPartition(klass, dev_path):
115        args = ['/sbin/newfs_msdos']
116        if hasattr(klass, 'volumeName'):
117            args.extend(['-v', klass.volumeName])
118        args.append(dev_path)
119        klass.runProcess(args, 0)
120
121    @classmethod
122    def setUpClass(klass):
123        if klass.imagePath:
124            klass.createDiskImage()
125            klass.attachDiskImage()
126        else:
127            assert klass.device is not None
128            klass.partition = "/dev/"+klass.device
129
130        # Format the device
131        if VERBOSE:
132            print "-> Formatting %s" % klass.partition
133        klass.formatPartition(klass.partition)
134
135        # If there is a prepareContent class method, then "mount" the volume
136        # and call the prepareContent method.
137        if hasattr(klass, 'prepareContent'):
138            with open(klass.partition, "r+") as dev:
139                volume = msdosfs.msdosfs(dev)
140                klass.prepareContent(volume)
141                volume.flush()
142
143    @classmethod
144    def tearDownClass(klass):
145        if klass.imagePath:
146            klass.ejectDiskImage()
147            # Don't bother deleting the disk image
148
149    def setUp(self):
150        if VERBOSE:
151            print       # So verbose output starts on line following test name
152        self.runProcess([FSCK_PATH, '-n', self.device], 0)
153
154    def tearDown(self):
155        self.runProcess([FSCK_PATH, '-n', self.device], 0)
156
157    def verifyDirty(self):
158        "Verify the volume is marked dirty."
159        expectedErr = 'QUICKCHECK ONLY; FILESYSTEM DIRTY\n'
160        self.runProcess([FSCK_PATH, '-q', self.device], 3, '', expectedErr)
161
162    def verifyClean(self):
163        "Verify the volume is marked clean."
164        expectedErr = 'QUICKCHECK ONLY; FILESYSTEM CLEAN\n'
165        self.runProcess([FSCK_PATH, '-q', self.device], 0, '', expectedErr)
166
167class TestEmptyVolume(UserFSTestCase):
168    volumeName = 'EMPTY'
169
170    def testInfoRoot(self):
171        args = ["--info", "/"]
172        expected = "        4096  dir   Jan  1 00:00:00.00 1980  /\n"
173        self.runTool(args, 0, expected, "")
174
175    def testListRoot(self):
176        args = ["--list", "/"]
177        expected = ""
178        self.runTool(args, 0, expected, "")
179
180    def testInfoMissingFile(self):
181        args = ["--info", "/missing"]
182        expectedErr = "userfs_tool: /missing: The operation couldn’t be completed. No such file or directory\n"
183        self.runTool(args, 1, "", expectedErr)
184
185    def testReadMissingFile(self):
186        args = ["--read", "/missing"]
187        expectedErr = "userfs_tool: /missing: The operation couldn’t be completed. No such file or directory\n"
188        self.runTool(args, 1, "", expectedErr)
189
190    def testSHA1MissingFile(self):
191        args = ["--sha", "/missing"]
192        expectedErr = "userfs_tool: /missing: The operation couldn’t be completed. No such file or directory\n"
193        self.runTool(args, 1, "", expectedErr)
194
195    def testDeleteMissingFile(self):
196        args = ["--delete", "/missing"]
197        expectedErr = "userfs_tool: /missing: The operation couldn’t be completed. No such file or directory\n"
198        self.runTool(args, 1, "", expectedErr)
199
200class TestFileInfo(UserFSTestCase):
201    volumeName = 'FILEINFO'
202
203    @classmethod
204    def prepareContent(klass, volume):
205        def makeFiles(parent):
206            parent.mkfile('UPPER.TXT')  # Short name, all upper case
207            parent.mkfile('lower.txt')  # Short name, all lower case
208            parent.mkfile('mixed.TXT')  # Short name, part lower and part upper
209            parent.mkfile('MyFile.txt') # Long name (mixed case, 1 long name entry)
210            # A long name that ends on a directory entry boundary; no terminator or padding (26 chars)
211            parent.mkfile('abcdefghijklmnopqrstuvwxyz')
212            # A long name with a terminator, but no padding (25 chars)
213            parent.mkfile('The quick brown fox jumps')
214            # A long name of maximum length
215            parent.mkfile('x'*255)
216            # A locked file
217            parent.mkfile('Locked', attributes=ATTR_ARCHIVE|ATTR_READ_ONLY)
218            # A hidden file
219            parent.mkfile('Hidden', attributes=ATTR_ARCHIVE|ATTR_HIDDEN)
220            # A "system" file
221            parent.mkfile('System', attributes=ATTR_ARCHIVE|ATTR_SYSTEM)
222            # A deleted entry
223            parent.mkfile('xDeleted', deleted=True)
224#     - In root directory
225#         + FAT12
226#         + FAT16
227#         √ FAT32
228
229        def makeMoreLongNames(parent):
230            slotsPerCluster = volume.bytesPerCluster / 32
231
232            # Make a long name starting at the beginning of a cluster
233            raw = make_long_dirent("Start of Cluster", ATTR_ARCHIVE)
234            parent.fill_slots(slotsPerCluster)
235            parent.write_slots(slotsPerCluster, raw)
236
237            # Make a long name that crosses a cluster boundary
238            raw = make_long_dirent("Crossing a Cluster", ATTR_ARCHIVE)
239            slot = slotsPerCluster * 2 - 1
240            parent.fill_slots(slot)
241            parent.write_slots(slot, raw)
242
243            # Make a long name, where the short name entry is at the end of
244            # the directory cluster (and end of directory)
245            raw = make_long_dirent("End of Cluster", ATTR_ARCHIVE)
246            slot = slotsPerCluster * 3 - (len(raw)/32)
247            parent.fill_slots(slot)
248            parent.write_slots(slot, raw)
249
250        def makeDates(parent):
251            parent.mkfile('Halloween', modDate=Timestamp(10,31,2013,17,0,0))
252            parent.mkfile("Valentine's", createDate=Timestamp(2,14,2013,22,33,44.551))
253            parent.mkfile("Loma Prieta", createDate=Timestamp(10,17,1989,17,4,6.9), modDate=Timestamp(10,17,1989,17,4,0))
254
255        root = volume.root()
256        clusters = volume.fat.find(3)
257        subdir = root.mkdir('SUBDIR', clusters=clusters)
258        dates = root.mkdir('Dates')
259        makeFiles(root)
260        makeFiles(subdir)
261        makeMoreLongNames(subdir)
262        makeDates(dates)
263
264    def testRootItems(self):
265        args = '--list /'.split()
266        expectedOut = """\
267       12288  dir   Jan  1 00:00:00.00 1980  /SUBDIR
268        4096  dir   Jan  1 00:00:00.00 1980  /Dates
269           0  file  Jan  1 00:00:00.00 1980  /UPPER.TXT
270           0  file  Jan  1 00:00:00.00 1980  /lower.txt
271           0  file  Jan  1 00:00:00.00 1980  /mixed.TXT
272           0  file  Jan  1 00:00:00.00 1980  /MyFile.txt
273           0  file  Jan  1 00:00:00.00 1980  /abcdefghijklmnopqrstuvwxyz
274           0  file  Jan  1 00:00:00.00 1980  /The quick brown fox jumps
275           0  file  Jan  1 00:00:00.00 1980  /xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
276           0 #file  Jan  1 00:00:00.00 1980  /Locked
277           0  file  Jan  1 00:00:00.00 1980  /Hidden
278           0  file  Jan  1 00:00:00.00 1980  /System
279"""
280        self.runTool(args, 0, expectedOut, '')
281
282    def testSubdirItems(self):
283        args = '--list /SUBDIR'.split()
284        expectedOut = """\
285           0  file  Jan  1 00:00:00.00 1980  /SUBDIR/UPPER.TXT
286           0  file  Jan  1 00:00:00.00 1980  /SUBDIR/lower.txt
287           0  file  Jan  1 00:00:00.00 1980  /SUBDIR/mixed.TXT
288           0  file  Jan  1 00:00:00.00 1980  /SUBDIR/MyFile.txt
289           0  file  Jan  1 00:00:00.00 1980  /SUBDIR/abcdefghijklmnopqrstuvwxyz
290           0  file  Jan  1 00:00:00.00 1980  /SUBDIR/The quick brown fox jumps
291           0  file  Jan  1 00:00:00.00 1980  /SUBDIR/xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
292           0 #file  Jan  1 00:00:00.00 1980  /SUBDIR/Locked
293           0  file  Jan  1 00:00:00.00 1980  /SUBDIR/Hidden
294           0  file  Jan  1 00:00:00.00 1980  /SUBDIR/System
295           0  file  Jan  1 00:00:00.00 1980  /SUBDIR/Start of Cluster
296           0  file  Jan  1 00:00:00.00 1980  /SUBDIR/Crossing a Cluster
297           0  file  Jan  1 00:00:00.00 1980  /SUBDIR/End of Cluster
298"""
299        self.runTool(args, 0, expectedOut, '')
300
301    def testRecursive(self):
302        args = '--list --recursive /'.split()
303        expectedOut = """\
304       12288  dir   Jan  1 00:00:00.00 1980  /SUBDIR
305           0  file  Jan  1 00:00:00.00 1980  /SUBDIR/UPPER.TXT
306           0  file  Jan  1 00:00:00.00 1980  /SUBDIR/lower.txt
307           0  file  Jan  1 00:00:00.00 1980  /SUBDIR/mixed.TXT
308           0  file  Jan  1 00:00:00.00 1980  /SUBDIR/MyFile.txt
309           0  file  Jan  1 00:00:00.00 1980  /SUBDIR/abcdefghijklmnopqrstuvwxyz
310           0  file  Jan  1 00:00:00.00 1980  /SUBDIR/The quick brown fox jumps
311           0  file  Jan  1 00:00:00.00 1980  /SUBDIR/xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
312           0 #file  Jan  1 00:00:00.00 1980  /SUBDIR/Locked
313           0  file  Jan  1 00:00:00.00 1980  /SUBDIR/Hidden
314           0  file  Jan  1 00:00:00.00 1980  /SUBDIR/System
315           0  file  Jan  1 00:00:00.00 1980  /SUBDIR/Start of Cluster
316           0  file  Jan  1 00:00:00.00 1980  /SUBDIR/Crossing a Cluster
317           0  file  Jan  1 00:00:00.00 1980  /SUBDIR/End of Cluster
318        4096  dir   Jan  1 00:00:00.00 1980  /Dates
319           0  file  Oct 31 17:00:00.00 2013  /Dates/Halloween
320           0  file  Jan  1 00:00:00.00 1980  /Dates/Valentine's
321           0  file  Oct 17 17:04:00.00 1989  /Dates/Loma Prieta
322           0  file  Jan  1 00:00:00.00 1980  /UPPER.TXT
323           0  file  Jan  1 00:00:00.00 1980  /lower.txt
324           0  file  Jan  1 00:00:00.00 1980  /mixed.TXT
325           0  file  Jan  1 00:00:00.00 1980  /MyFile.txt
326           0  file  Jan  1 00:00:00.00 1980  /abcdefghijklmnopqrstuvwxyz
327           0  file  Jan  1 00:00:00.00 1980  /The quick brown fox jumps
328           0  file  Jan  1 00:00:00.00 1980  /xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
329           0 #file  Jan  1 00:00:00.00 1980  /Locked
330           0  file  Jan  1 00:00:00.00 1980  /Hidden
331           0  file  Jan  1 00:00:00.00 1980  /System
332"""
333        self.runTool(args, 0, expectedOut, '')
334
335    def testCaseInsensitiveLookup(self):
336        args = '--info /upper.txt /LOWER.TXT /MiXeD.TxT /myfile.txt'.split()
337        args.append('/The Quick Brown Fox Jumps')
338        args.append('/locked')
339
340        # TODO: Should we enforce that the printed name be the same case as on-disk?
341        expectedOut = """\
342           0  file  Jan  1 00:00:00.00 1980  /upper.txt
343           0  file  Jan  1 00:00:00.00 1980  /LOWER.TXT
344           0  file  Jan  1 00:00:00.00 1980  /MiXeD.TxT
345           0  file  Jan  1 00:00:00.00 1980  /myfile.txt
346           0  file  Jan  1 00:00:00.00 1980  /The Quick Brown Fox Jumps
347           0 #file  Jan  1 00:00:00.00 1980  /locked
348"""
349        self.runTool(args, 0, expectedOut, '')
350
351    def testDates(self):
352        args = '--list --dates /Dates'.split()
353        expectedOut = """\
354           0  file  Jan  1 00:00:00.00 1980  Oct 31 17:00:00.00 2013  /Dates/Halloween
355           0  file  Feb 14 22:33:44.55 2013  Jan  1 00:00:00.00 1980  /Dates/Valentine's
356           0  file  Oct 17 17:04:06.90 1989  Oct 17 17:04:00.00 1989  /Dates/Loma Prieta
357"""
358        self.runTool(args, 0, expectedOut, '')
359
360class TestDelete(UserFSTestCase):
361    volumeName = 'DELETE'
362
363    #
364    # Because we'll be modifying the test volume, we need to be careful that
365    # the test cases get executed in a specific order so that our expected
366    # output is correct.  To make the order obvious and predictable, the
367    # test case names have a number to control the ordering (since tests are
368    # run in order by name).
369    #
370    # * Deleting Files
371    #     - initial state
372    #     - existing file
373    #         + make sure it's gone afterwards
374    #     - missing file
375    #     - same file twice (two separate userfs_tool invocations)
376    #     - same file twice (one userfs_tool invocation)
377    #     - multiple files
378    #     - directory
379    #         + make sure it's still there
380    #     - locked file
381    #         + make sure it's still there
382    #     - file with multiple clusters in same FAT block
383    #     - file with clusters in multiple FAT blocks
384    #     - empty file
385    #     - Long name, long entries at start of cluster
386    #     - Long name, long entries cross cluster boundary
387    #     - Short name
388    #     - File inside subdirectory
389    #     - final state
390    #     ? recursive
391
392    @classmethod
393    def prepareContent(klass, volume):
394        bytesPerCluster = volume.bytesPerCluster
395        slotsPerCluster = bytesPerCluster / 32
396
397        root = volume.root()
398        root.mkfile('Empty')
399        root.mkfile('Twice1', content='Delete me twice')
400        root.mkfile('Multi1', content='Multi1')
401        root.mkfile('Multi2', content='Multi2')
402        root.mkfile('Twice2', content='Delete me twice in a single call')
403        root.mkfile('Existing', content='Existing')
404        root.mkfile('Locked', content='Locked', attributes=ATTR_ARCHIVE | ATTR_READ_ONLY)
405        root.mkfile('Keep Me', content='Keep Me')
406        root.mkfile('This file has a long Unicode name', content='This file has a long Unicode name')
407
408        # Make a subdirectory and a simple file inside it
409        clusters = volume.fat.find(2)
410        sub = root.mkdir('Subdirectory', clusters=clusters)
411        sub.mkfile('Child', content='Child')
412
413        # Make a file whose directory entries cross a cluster boundary
414        clusters = volume.fat.find(1)
415        volume.fat.chain(clusters)
416        if volume.fsinfo:
417            volume.fsinfo.allocate(1)
418        raw = make_long_dirent("Crossing Cluster Boundary", ATTR_ARCHIVE, head=clusters[0], length=25)
419        slot = slotsPerCluster - 1
420        sub.fill_slots(slot)
421        sub.write_slots(slot, raw)
422        crossing = volume.Chain(volume, clusters[0], 25)
423        crossing.pwrite(0, "Crossing Cluster Boundary")
424
425        root.mkfile('Contiguous', content='I am a contiguous file!\n'*4321)
426
427        # Make a file with 3 "extents" of 2, 3, and 4 clusters
428        clusters = volume.fat.find(11)
429        del clusters[6]
430        del clusters[3]
431        volume.fat.chain(clusters)
432        if volume.fsinfo:
433            volume.fsinfo.allocate(len(clusters))
434        length = len(clusters) * bytesPerCluster - 37
435        root.mkfile('Extents', length=length, clusters=clusters)
436        klass.extentsSize = length
437
438        # Make a file with clusters spread out on the disk
439        head = volume.fat.find(1)[0]
440        clusters = range(head, volume.maxcluster, 7777)
441        random.Random("Delete").shuffle(clusters)
442        volume.fat.chain(clusters)
443        if volume.fsinfo:
444            volume.fsinfo.allocate(len(clusters))
445        length = len(clusters) * bytesPerCluster - 66
446        root.mkfile('Fragmented', length=length, clusters=clusters)
447        klass.fragmentedSize = length
448
449    def test00VerifyInitialState(self):
450        args = '--list --recursive /'.split()
451        expected = """\
452           0  file  Jan  1 00:00:00.00 1980  /Empty
453          15  file  Jan  1 00:00:00.00 1980  /Twice1
454           6  file  Jan  1 00:00:00.00 1980  /Multi1
455           6  file  Jan  1 00:00:00.00 1980  /Multi2
456          32  file  Jan  1 00:00:00.00 1980  /Twice2
457           8  file  Jan  1 00:00:00.00 1980  /Existing
458           6 #file  Jan  1 00:00:00.00 1980  /Locked
459           7  file  Jan  1 00:00:00.00 1980  /Keep Me
460          33  file  Jan  1 00:00:00.00 1980  /This file has a long Unicode name
461        8192  dir   Jan  1 00:00:00.00 1980  /Subdirectory
462           5  file  Jan  1 00:00:00.00 1980  /Subdirectory/Child
463          25  file  Jan  1 00:00:00.00 1980  /Subdirectory/Crossing Cluster Boundary
464      103704  file  Jan  1 00:00:00.00 1980  /Contiguous
465{0:12d}  file  Jan  1 00:00:00.00 1980  /Extents
466{1:12d}  file  Jan  1 00:00:00.00 1980  /Fragmented
467""".format(self.extentsSize, self.fragmentedSize)
468        self.runTool(args, 0, expected, '')
469
470#         args = '--sha /Fragmented'.split()
471#         expected = 'ed24feba8bcf0ec4aaaebcd8391981109ae0ec25  /Fragmented\n'
472#         self.runTool(args, 0, expected, '')
473
474    def test01DeleteExistingFile(self):
475        args = '--delete /Existing'.split()
476        expected = ''
477        self.runTool(args, 0, expected, '')
478
479        args = '--info /Existing'.split()
480        expectedErr = 'userfs_tool: /Existing: The operation couldn’t be completed. No such file or directory\n'
481        self.runTool(args, 1, '', expectedErr)
482
483    def test02DeleteMissingFile(self):
484        args = '--delete /Missing'.split()
485        expectedErr = 'userfs_tool: /Missing: The operation couldn’t be completed. No such file or directory\n'
486        self.runTool(args, 1, '', expectedErr)
487
488    def test03DeleteFileTwice(self):
489        args = '--delete /Twice1'.split()
490        expected = ''
491        self.runTool(args, 0, expected, '')
492
493        args = '--delete /Twice1'.split()
494        expectedErr = 'userfs_tool: /Twice1: The operation couldn’t be completed. No such file or directory\n'
495        self.runTool(args, 1, '', expectedErr)
496
497    def test04DeleteFileTwiceInOneCall(self):
498        args = '--delete /Twice2 /Twice2'.split()
499        expectedErr = 'userfs_tool: /Twice2: The operation couldn’t be completed. No such file or directory\n'
500        self.runTool(args, 1, '', expectedErr)
501
502        args = '--info /Twice2'.split()
503        expectedErr = 'userfs_tool: /Twice2: The operation couldn’t be completed. No such file or directory\n'
504        self.runTool(args, 1, '', expectedErr)
505
506    def test05DeleteMultipleFiles(self):
507        args = '--delete /Multi1 /Multi2'.split()
508        expected = ''
509        self.runTool(args, 0, expected, '')
510
511        args = '--info /Multi1'.split()
512        expectedErr = 'userfs_tool: /Multi1: The operation couldn’t be completed. No such file or directory\n'
513        self.runTool(args, 1, '', expectedErr)
514
515        args = '--info /Multi2'.split()
516        expectedErr = 'userfs_tool: /Multi2: The operation couldn’t be completed. No such file or directory\n'
517        self.runTool(args, 1, '', expectedErr)
518
519    def test06DeleteDirectory(self):
520        args = '--delete /Subdirectory'.split()
521        expectedErr = 'userfs_tool: delete_item: The operation couldn’t be completed. Is a directory\n'
522        self.runTool(args, 1, '', expectedErr)
523
524    def test07DeleteLockedFile(self):
525        args = '--delete /Locked'.split()
526        expectedErr = 'userfs_tool: delete_item: The operation couldn’t be completed. Operation not permitted\n'
527        self.runTool(args, 1, '', expectedErr)
528
529        # Make sure the file still exists
530        args = '--info /Locked'.split()
531        expected = '           6 #file  Jan  1 00:00:00.00 1980  /Locked\n'
532        self.runTool(args, 0, expected, '')
533
534    def test09DeleteEmptyFile(self):
535        args = '--delete /Empty'.split()
536        expected = ''
537        self.runTool(args, 0, expected, '')
538
539    def test11DeleteFileInDir(self):
540        args = '--delete /Subdirectory/Child'.split()
541        expected = ''
542        self.runTool(args, 0, expected, '')
543
544        args = '--info /Subdirectory/Child'.split()
545        expectedErr = 'userfs_tool: /Subdirectory/Child: The operation couldn’t be completed. No such file or directory\n'
546        self.runTool(args, 1, '', expectedErr)
547
548    # Delete a file that contains multiple, contiguous clusters
549    def test12DeleteContiguousFile(self):
550        args = '--delete /Contiguous'.split()
551        expected = ''
552        self.runTool(args, 0, expected, '')
553
554        args = '--info /Contiguous'.split()
555        expectedErr = 'userfs_tool: /Contiguous: The operation couldn’t be completed. No such file or directory\n'
556        self.runTool(args, 1, '', expectedErr)
557
558    # Delete a file that contains multiple multi-cluster "extents"
559    def test13DeleteExtentsFile(self):
560        args = '--delete /Extents'.split()
561        expected = ''
562        self.runTool(args, 0, expected, '')
563
564        args = '--info /Extents'.split()
565        expectedErr = 'userfs_tool: /Extents: The operation couldn’t be completed. No such file or directory\n'
566        self.runTool(args, 1, '', expectedErr)
567
568    # Delete a file that contains clusters from multiple FAT blocks
569    def test14DeleteFragmentedFile(self):
570        args = '--delete /Fragmented'.split()
571        expected = ''
572        self.runTool(args, 0, expected, '')
573
574        args = '--info /Fragmented'.split()
575        expectedErr = 'userfs_tool: /Fragmented: The operation couldn’t be completed. No such file or directory\n'
576        self.runTool(args, 1, '', expectedErr)
577
578    # Delete a file whose directory entries cross a cluster boundary
579    def test15DeleteCrossingFile(self):
580        args = ['--delete', '/Subdirectory/Crossing Cluster Boundary']
581        expected = ''
582        self.runTool(args, 0, expected, '')
583
584        args = ['--info', '/Subdirectory/Crossing Cluster Boundary']
585        expectedErr = 'userfs_tool: /Subdirectory/Crossing Cluster Boundary: The operation couldn’t be completed. No such file or directory\n'
586        self.runTool(args, 1, '', expectedErr)
587
588    def test16DeleteLongName(self):
589        args = ['--delete', '/This file has a long Unicode name']
590        expected = ''
591        self.runTool(args, 0, expected, '')
592
593        args = ['--info', '/This file has a long Unicode name']
594        expectedErr = 'userfs_tool: /This file has a long Unicode name: The operation couldn’t be completed. No such file or directory\n'
595        self.runTool(args, 1, '', expectedErr)
596
597    def test99VerifyFinalState(self):
598        args = '--list --recursive /'.split()
599        expected = """\
600           6 #file  Jan  1 00:00:00.00 1980  /Locked
601           7  file  Jan  1 00:00:00.00 1980  /Keep Me
602        8192  dir   Jan  1 00:00:00.00 1980  /Subdirectory
603"""
604        self.runTool(args, 0, expected, '')
605
606if __name__ == "__main__":
607    # main(sys.argv)
608    import getopt
609
610    #
611    # The default fsck_exfat to run, and the default path to the sparse bundle
612    # disk image used for testing.
613    #
614    FSCK_PATH = None
615    VERBOSE = False
616    DIFFS = False
617    KEEP_IMAGE = False
618    BUILT_PRODUCTS_DIR = os.environ.get('BUILT_PRODUCTS_DIR', None)
619    TOOL_PATH = os.environ.get('TOOL_PATH', None)
620    TOOL_ENV = None
621
622    #
623    # Let the path to fsck_exfat and the path to the sparse bundle image be
624    # overridden on the command line.  We also parse the options that
625    # unittest.main() parses; we just collect them and pass them on for
626    # unittest to handle.  Note that options need to precede arguments
627    # because getopt stops parsing options once it finds a non-option argument.
628    #
629    # NOTE: The "-V" option sets our VERBOSE variable as well as passing "-v"
630    # to unittest.main.
631    #
632    # Hmm.  I could probably have just subclassed unittest.TestProgram and
633    # overridden the parseArgs method to handle my additional arguments.  But
634    # then I'd have to assign the globals to self.__class__.__module__ so the
635    # test cases could see them.  Or perhaps get the values from environment
636    # variables.
637    #
638    # TODO: Need an option to use a locally built framework.  Perhaps either
639    # via a local Xcode build, or buildit.
640    #
641    # TODO: Need an option to use a locally built userfs_tool.  Perhaps either
642    # via a local Xcode build, or buildit.
643    #
644    # TODO: Would it be sufficient to just point at a root, and grab both tool
645    # and framework from that?  Or perhaps default to using the
646    # $BUILT_PRODUCTS_DIR from the current working copy's Debug configuration?
647    #
648    argv = [sys.argv[0]]
649    options, args = getopt.getopt(sys.argv[1:], "hHvVq",
650                        "help verbose quiet keep repair= fsck= dir= img= device=".split())
651    for opt, value in options:
652        if opt == '--keep':
653            KEEP_IMAGE = True
654        elif opt == "--fsck":
655            FSCK_PATH = value
656        elif opt == "--dir":
657            UserFSTestCase.imagePath = os.path.join(value, "FAT_TestCase")
658        elif opt == "--img":
659            UserFSTestCase.imagePath = value
660        elif opt == "--device":
661            UserFSTestCase.device = value
662            UserFSTestCase.imagePath = None
663        elif opt == "-V":
664            VERBOSE = True
665            DIFFS = True
666            argv.append("-v")
667        elif opt == "-v":
668            DIFFS = True
669            argv.append(opt)
670        else:
671            # NOTE: the normal unittest options don't have arguments
672            argv.append(opt)
673    argv.extend(args)
674
675    #
676    # Since this script is meant primarily for pre-submission testing by the
677    # engineer making the change, assume the engineer has built the Debug
678    # configuration, and wants to use that built version of the framework
679    # and tool.
680    #
681    if BUILT_PRODUCTS_DIR is None:
682        args = "xcodebuild -scheme All_iOS -configuration Debug -showBuildSettings".split()
683        p = subprocess.Popen(args, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
684        stdout, stderr = p.communicate()
685        assert(p.returncode == 0)
686
687        BUILT_PRODUCTS_DIR = None
688        setting = "    BUILT_PRODUCTS_DIR = "
689        for line in stdout.splitlines():
690            if line.startswith(setting):
691                BUILT_PRODUCTS_DIR = line[len(setting):]      # Delete setting name from start of string
692        assert(BUILT_PRODUCTS_DIR is not None)
693        BUILT_PRODUCTS_DIR = BUILT_PRODUCTS_DIR.rstrip("/")
694
695    if TOOL_PATH is None:
696        TOOL_PATH = os.path.join(BUILT_PRODUCTS_DIR, 'userfs_tool')
697    if TOOL_ENV is None:
698        TOOL_ENV = dict(DYLD_FRAMEWORK_PATH=BUILT_PRODUCTS_DIR, TZ="PST8PDT7")
699
700    # If we don't have a path to fsck_exfat, and it was built, use it.
701    if FSCK_PATH is None:
702        path = os.path.join(BUILT_PRODUCTS_DIR, 'fsck_msdos')
703        if os.path.isfile(path):
704            FSCK_PATH = path
705
706    # If no fsck_exfat was found/given, then default to the built-in one.
707    if FSCK_PATH is None:
708        FSCK_PATH = '/sbin/fsck_msdos'
709
710    unittest.main(argv=argv)
711