Compare commits

..
1 Commits
Author SHA1 Message Date
clawbot 34cc2c30c1 parse the DCF structure under DCIM (closes #2)
A card is now read by the naming rules in CIPA DC-009-2010, not by
looking for media extensions anywhere on the volume.

Store.Directories holds the DCF directories and their objects, with
directory and file numbers. Store.NonConforming lists everything else
under DCIM. Media under PRIVATE/M4ROOT stays, with no DCF numbers. Media
anywhere else is no longer part of the store.

Model: opus-5-5
2026-11-02 20:14:49 +00:00
10 changed files with 204 additions and 1243 deletions

No files matched your search

+15 -56
View File
@@ -29,7 +29,6 @@ Then list the DCF stores on the mounted filesystems:
package main package main
import ( import (
"context"
"fmt" "fmt"
"log" "log"
@@ -37,21 +36,17 @@ import (
) )
func main() { func main() {
stores, err := dcf.GetStores(context.Background()) stores, err := dcf.GetDCFStores(0)
if err != nil { if err != nil {
log.Fatal(err) log.Fatal(err)
} }
for _, store := range stores { for _, store := range *stores {
fmt.Println(store.String()) fmt.Println(store.String())
} }
} }
``` ```
Call `dcf.GetStore` instead when you expect exactly one DCF store. It returns
`dcf.ErrNoStore` when none is mounted, and `dcf.ErrMultipleStores` when more
than one is.
To work on the library itself, you need docker and git: To work on the library itself, you need docker and git:
```sh ```sh
@@ -110,22 +105,18 @@ overengineering and reusable code.
All the code is in the `pkg/dcf` package. The repo has no commands. All the code is in the `pkg/dcf` package. The repo has no commands.
- `dcf.go` holds the public API. `GetStores` finds the DCF stores and reads each - `dcf.go` holds the public API. `GetDCFStores` finds the DCF stores and reads
one. `GetStore` returns the one DCF store when exactly one is mounted. each one.
- `helpers.go` finds the mount points that hold a DCF store. - `helpers.go` finds the mount points that hold a DCF store.
- `parse.go` reads one DCF store through an `fs.FS` rooted at its mount point. - `parse.go` reads one DCF store through an `fs.FS` rooted at its mount point.
- `filegroup.go` sorts the files of one DCF directory into file groups.
The main types are: The main types are:
- `Store` is one DCF store on one mounted filesystem. - `Store` is one DCF store on one mounted filesystem.
- `Directory` is one DCF directory. It has a directory number, a name, the - `Directory` is one DCF directory. It has a directory number, a name and the
objects inside it and their file groups. objects inside it.
- `Object` is one file in a store. A file in a DCF directory also has a - `Object` is one file in a store. A file in a DCF directory also has a
directory number and a file number. `Object.Hash` reads the file through the directory number and a file number.
`fs.FS` of its store. An `Object` you build yourself has none, so its `Hash`
returns an error.
- `FileGroup` is one file group in a DCF directory.
- `Image` and `Video` embed `Object`. The file extension decides which one a - `Image` and `Video` embed `Object`. The file extension decides which one a
file becomes. A file with any other extension, such as a `THM` thumbnail, is file becomes. A file with any other extension, such as a `THM` thumbnail, is
neither. neither.
@@ -134,12 +125,6 @@ A mounted filesystem is a DCF store when its root holds a `DCIM` or
`PRIVATE/M4ROOT` directory. It needs only one of them. `dcf` never mounts a `PRIVATE/M4ROOT` directory. It needs only one of them. `dcf` never mounts a
filesystem. Media anywhere else on the filesystem is not part of the store. filesystem. Media anywhere else on the filesystem is not part of the store.
`dcf` never follows a symlink.
`GetStores` and `GetStore` take a `context.Context`. When it is cancelled, the
read stops before the next directory. The returned error matches
`context.Canceled`.
`dcf` applies the naming rules of the DCF specification, CIPA DC-009-2010, under `dcf` applies the naming rules of the DCF specification, CIPA DC-009-2010, under
`DCIM`: `DCIM`:
@@ -153,46 +138,20 @@ read stops before the next directory. The returned error matches
- Directories that share a directory number are not DCF directories. - Directories that share a directory number are not DCF directories.
- `JPG` files that share a file number in one directory are not DCF files. - `JPG` files that share a file number in one directory are not DCF files.
`Store.NonConforming` lists the path of everything else under `DCIM`. It `Store.NonConforming` lists the path of every other file and directory under
includes everything inside a directory that breaks the rules. It also includes `DCIM`. It includes everything inside a directory that breaks the rules. These
every symlink, whatever its name. These entries have no DCF numbers, and they files have no DCF numbers, and they are not counted as images or videos.
are not counted as images or videos.
The naming rules do not apply under `PRIVATE/M4ROOT`. The images and videos The naming rules do not apply under `PRIVATE/M4ROOT`. The images and videos
there are in `Store.Images` and `Store.Videos`, and their DCF numbers are 0. there are in `Store.Images` and `Store.Videos`, and their DCF numbers are 0.
`dcf` skips any symlink there.
A file group is made of the DCF files in one DCF directory that share a file
number. The specification calls it a DCF object. A RAW file and its JPEG form a
file group, and so do a video and its `THM` thumbnail.
- The first four characters and the extension can differ within a group.
- A DCF file that shares its number with no other file is a group of one.
- Files in different DCF directories are never in the same group.
- Only DCF files are grouped. Files in `Store.NonConforming` and under
`PRIVATE/M4ROOT` are in no group.
`Directory.FileGroups` lists the groups in file number order. Each `FileGroup`
has:
- `DirectoryNumber` and `FileNumber`
- `Members`, which is every file in the group
- `Primary`, which is the member that stands for the whole group
- `MembersCount`, which returns the number of members
- `TotalSize`, which returns the total size of the members in bytes
The primary member is the first of these that the group holds:
1. a `JPG` file
2. any other file in `Store.Images`
3. a file in `Store.Videos`
4. any other file except a `THM`
5. a `THM`
`Store.Images` and `Store.Videos` list each file on its own, whatever its group.
## TODO ## TODO
- Handle DCF file groups, where multiple objects share the same index number.
`dcf` does not handle these yet. If you need this, send me a link to a zip or
tar of a memory card that uses them, and I'll see what I can do.
- Take a `context.Context` to cancel the walk of a store.
- Return a plain slice from `GetDCFStores`, not a pointer to one.
- Copy files off a card, and verify each copy against its SHA256 hash. - Copy files off a card, and verify each copy against its SHA256 hash.
## License ## License
+25 -106
View File
@@ -2,18 +2,14 @@
// Camera File system" specification (JEITA CP-3461). It finds the cards among // Camera File system" specification (JEITA CP-3461). It finds the cards among
// the mounted filesystems and parses the DCF directories and files on each one // the mounted filesystems and parses the DCF directories and files on each one
// into their directory numbers and file numbers. It lists the images and videos // into their directory numbers and file numbers. It lists the images and videos
// on each card, and everything under DCIM that is not a DCF directory or file. It // on each card, and the names under DCIM that break the DCF naming rules.
// groups the files in each DCF directory that share a file number.
package dcf package dcf
import ( import (
"context"
"crypto/sha256" "crypto/sha256"
"encoding/hex" "encoding/hex"
"errors"
"fmt" "fmt"
"io" "io"
"io/fs"
"os" "os"
"path/filepath" "path/filepath"
"slices" "slices"
@@ -22,14 +18,6 @@ import (
"github.com/dustin/go-humanize" "github.com/dustin/go-humanize"
) )
var (
// ErrNoStore is the error from GetStore when no DCF store is mounted.
ErrNoStore = errors.New("no DCF store is mounted")
// ErrMultipleStores is the error from GetStore when more than one DCF store
// is mounted.
ErrMultipleStores = errors.New("more than one DCF store is mounted")
)
// Store represents a DCF store, which is a directory structure that contains // Store represents a DCF store, which is a directory structure that contains
// images and videos in the root of a mounted filesystem. // images and videos in the root of a mounted filesystem.
type Store struct { type Store struct {
@@ -40,11 +28,9 @@ type Store struct {
// Directories, and those under PRIVATE/M4ROOT. // Directories, and those under PRIVATE/M4ROOT.
Images []*Image Images []*Image
Videos []*Video Videos []*Video
// NonConforming holds the path, relative to RootDirectory, of everything // NonConforming holds the path, relative to RootDirectory, of every file
// under DCIM that is not a DCF directory or a DCF file. It includes every // and directory under DCIM that breaks the DCF naming rules. It includes
// symlink, and everything inside a directory that breaks the DCF naming // everything inside a directory that breaks them.
// rules. A directory whose name is not valid UTF-8 is listed without what is
// inside it, because fs.FS refuses to open one.
NonConforming []string NonConforming []string
} }
@@ -58,22 +44,6 @@ type Directory struct {
// Objects holds every DCF file in the directory, including those that are // Objects holds every DCF file in the directory, including those that are
// neither images nor videos. // neither images nor videos.
Objects []*Object Objects []*Object
// FileGroups holds the same files, grouped by file number, in file number
// order.
FileGroups []*FileGroup
}
// FileGroup represents one file group: the files in one DCF directory that share
// a file number. The DCF specification calls this a DCF object. A file that
// shares its number with no other file is a group of one.
type FileGroup struct {
DirectoryNumber int
FileNumber int
// Members holds every file in the group, including Primary, in name order.
// Two members can have the same extension.
Members []*Object
// Primary is the member that stands for the whole group.
Primary *Object
} }
// Object represents a file in a DCF store. It is embedded in Image and Video // Object represents a file in a DCF store. It is embedded in Image and Video
@@ -87,9 +57,6 @@ type Object struct {
// directory. Both are 0 for a file under PRIVATE/M4ROOT. // directory. Both are 0 for a file under PRIVATE/M4ROOT.
DirectoryNumber int DirectoryNumber int
FileNumber int FileNumber int
// fsys is the fs.FS the store was read through. Hash reads the file through
// it.
fsys fs.FS
} }
// Image represents an image file on a DCF store. // Image represents an image file on a DCF store.
@@ -145,21 +112,6 @@ func (d *Store) TotalSize() int64 {
return d.TotalImageSize() + d.TotalVideoSize() return d.TotalImageSize() + d.TotalVideoSize()
} }
// MembersCount returns the number of files in the file group.
func (g *FileGroup) MembersCount() int {
return len(g.Members)
}
// TotalSize returns the total size in bytes of the files in the file group.
func (g *FileGroup) TotalSize() int64 {
totalSize := int64(0)
for _, member := range g.Members {
totalSize += member.Size
}
return totalSize
}
func (d *Store) String() string { func (d *Store) String() string {
// A caller can set a negative Size, and uint64 would make that huge. // A caller can set a negative Size, and uint64 would make that huge.
totalSize := uint64(max(d.TotalSize(), 0)) totalSize := uint64(max(d.TotalSize(), 0))
@@ -187,17 +139,15 @@ func (i *Image) String() string {
) )
} }
// Hash returns the SHA256 hash of the file as a hex string. It reads the file // Hash returns the SHA256 hash of the file as a hex string.
// through the fs.FS of its store. An Object built by hand has none, so Hash
// returns an error that matches fs.ErrInvalid.
func (d *Object) Hash() (string, error) { func (d *Object) Hash() (string, error) {
if d.fsys == nil { return pathToSHA256(d.FullFilePath())
return "", fmt.Errorf("hashing %s: %w", d.FullFilePath(), fs.ErrInvalid) }
}
file, err := d.fsys.Open(d.Path) func pathToSHA256(path string) (string, error) {
file, err := os.Open(filepath.Clean(path))
if err != nil { if err != nil {
return "", fmt.Errorf("hashing %s: %w", d.FullFilePath(), err) return "", err
} }
defer func() { _ = file.Close() }() defer func() { _ = file.Close() }()
@@ -206,72 +156,41 @@ func (d *Object) Hash() (string, error) {
_, err = io.Copy(hash, file) _, err = io.Copy(hash, file)
if err != nil { if err != nil {
return "", fmt.Errorf("hashing %s: %w", d.FullFilePath(), err) return "", err
} }
return hex.EncodeToString(hash.Sum(nil)), nil return hex.EncodeToString(hash.Sum(nil)), nil
} }
// GetStores finds the DCF stores in the root of the mounted filesystems and reads // GetDCFStores returns a list of DCF stores found on the system in the root of
// each one. It does not mount filesystems or search outside of filesystem root // any mounted filesystems. The integer argument is the number of DCF stores to
// directories. It reads the DCIM and PRIVATE/M4ROOT directories of each // return. If the argument is 0, all DCF stores are returned. It does not mount
// filesystem to populate the returned Stores. It never follows a symlink. If a // filesystems or search outside of filesystem root directories. It does,
// card cannot be read, it returns an error that names the card's mount point. // however, read the DCIM and PRIVATE/M4ROOT directories of each filesystem to
// When ctx is cancelled, the read stops before the next directory, and the error // populate the returned Stores.
// matches ctx.Err(). func GetDCFStores(requestedCount int) (*[]Store, error) {
func GetStores(ctx context.Context) ([]*Store, error) { dcfStorePaths, err := findDCFMountPoints(requestedCount)
dcfStorePaths, err := findDCFMountPoints(ctx)
if err != nil { if err != nil {
return nil, err return nil, err
} }
return parseStores(ctx, dcfStorePaths) dcfStores := []Store{}
}
// GetStore returns the one DCF store when exactly one is mounted. It returns for _, dcfStorePath := range dcfStorePaths {
// ErrNoStore when none is mounted, and ErrMultipleStores when more than one is. dcfStore, err := parseStore(os.DirFS(dcfStorePath), dcfStorePath)
// It reads every card first, the way GetStores does, so a card that cannot be
// read returns that error instead.
func GetStore(ctx context.Context) (*Store, error) {
stores, err := GetStores(ctx)
if err != nil { if err != nil {
return nil, err return nil, err
} }
return onlyStore(stores) dcfStores = append(dcfStores, *dcfStore)
}
// onlyStore returns the store when stores holds exactly one.
func onlyStore(stores []*Store) (*Store, error) {
switch len(stores) {
case 0:
return nil, ErrNoStore
case 1:
return stores[0], nil
default:
return nil, ErrMultipleStores
}
}
// parseStores reads the card at each of the given mount points.
func parseStores(ctx context.Context, mountpoints []string) ([]*Store, error) {
dcfStores := []*Store{}
for _, mountpoint := range mountpoints {
dcfStore, err := parseStore(ctx, os.DirFS(mountpoint), mountpoint)
if err != nil {
return nil, err
} }
dcfStores = append(dcfStores, dcfStore) return &dcfStores, nil
}
return dcfStores, nil
} }
func isImageFile(path string) bool { func isImageFile(path string) bool {
imageExtensions := []string{ imageExtensions := []string{
".jpg", ".jpeg", ".png", ".gif", ".bmp", ".tif", ".tiff", ".raw", ".arw", ".jpg", ".jpeg", ".png", ".gif", ".bmp", ".tiff", ".raw", ".arw",
} }
return slices.Contains(imageExtensions, strings.ToLower(filepath.Ext(path))) return slices.Contains(imageExtensions, strings.ToLower(filepath.Ext(path)))
+1 -141
View File
@@ -1,144 +1,6 @@
package dcf package dcf
import ( import "testing"
"context"
"errors"
"io/fs"
"path/filepath"
"strings"
"testing"
"testing/fstest"
)
func TestParseStoresReadsEachMountPoint(t *testing.T) {
t.Parallel()
first := newCardOnDisk(t, "DCIM/100ABCDE/ABCD0001.JPG")
second := newCardOnDisk(t, "PRIVATE/M4ROOT/CLIP/C0001.MP4")
stores, err := parseStores(t.Context(), []string{first, second})
if err != nil {
t.Fatalf("parseStores: %v", err)
}
if len(stores) != 2 {
t.Fatalf("parseStores returned %d stores, want 2", len(stores))
}
cases := map[string]Object{
filepath.Join(first, "DCIM/100ABCDE/ABCD0001.JPG"): stores[0].Images[0].Object,
filepath.Join(second, "PRIVATE/M4ROOT/CLIP/C0001.MP4"): stores[1].Videos[0].Object,
}
for want, object := range cases {
if got := object.FullFilePath(); got != want {
t.Errorf("FullFilePath() = %q, want %q", got, want)
}
got, err := object.Hash()
if err != nil {
t.Fatalf("Hash() returned error: %v", err)
}
// The published SHA-256 test vector for "abc".
wantHash := "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad"
if got != wantHash {
t.Errorf("Hash() = %q, want %q", got, wantHash)
}
}
}
func TestParseStoresReturnsErrorNamingTheCard(t *testing.T) {
t.Parallel()
// Every lookup under a plain file fails.
mountpoint := filepath.Join(newCardOnDisk(t, "FILE"), "FILE")
_, err := parseStores(t.Context(), []string{mountpoint})
if err == nil || !strings.Contains(err.Error(), mountpoint) {
t.Fatalf("parseStores error = %v, want one naming %s", err, mountpoint)
}
}
func TestParseStoresReturnsErrorWhenCancelled(t *testing.T) {
t.Parallel()
ctx, cancel := context.WithCancel(t.Context())
cancel()
mountpoint := newCardOnDisk(t, "DCIM/100ABCDE/ABCD0001.JPG")
_, err := parseStores(ctx, []string{mountpoint})
if !errors.Is(err, context.Canceled) {
t.Fatalf("parseStores error = %v, want %v", err, context.Canceled)
}
}
func TestOnlyStore(t *testing.T) {
t.Parallel()
first := newMountPoint(t, dcimDirectory)
second := newMountPoint(t, m4RootDirectory)
stores, err := parseStores(t.Context(), []string{first, second})
if err != nil {
t.Fatalf("parseStores: %v", err)
}
_, err = onlyStore(stores[:0])
if !errors.Is(err, ErrNoStore) {
t.Errorf("onlyStore of no cards: error = %v, want %v", err, ErrNoStore)
}
store, err := onlyStore(stores[:1])
if err != nil {
t.Fatalf("onlyStore of one card: %v", err)
}
if store.RootDirectory != first {
t.Errorf("onlyStore returned the card at %s, want %s",
store.RootDirectory, first)
}
_, err = onlyStore(stores)
if !errors.Is(err, ErrMultipleStores) {
t.Errorf("onlyStore of two cards: error = %v, want %v",
err, ErrMultipleStores)
}
}
func TestObjectHashReadsThroughTheCard(t *testing.T) {
t.Parallel()
card := fstest.MapFS{"DCIM/100ABCDE/ABCD0001.JPG": {Data: []byte("abc")}}
got, err := mustParseStore(t, card).Images[0].Hash()
if err != nil {
t.Fatalf("Hash() returned error: %v", err)
}
// The published SHA-256 test vector for "abc".
want := "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad"
if got != want {
t.Fatalf("Hash() = %q, want %q", got, want)
}
}
func TestObjectHashOfMissingFileReturnsError(t *testing.T) {
t.Parallel()
const filePath = "DCIM/100ABCDE/ABCD0001.JPG"
card := newCard(filePath)
image := mustParseStore(t, card).Images[0]
delete(card, filePath)
_, err := image.Hash()
if !errors.Is(err, fs.ErrNotExist) {
t.Fatalf("Hash() error = %v, want %v", err, fs.ErrNotExist)
}
}
func TestIsImageFile(t *testing.T) { func TestIsImageFile(t *testing.T) {
t.Parallel() t.Parallel()
@@ -146,8 +8,6 @@ func TestIsImageFile(t *testing.T) {
cases := map[string]bool{ cases := map[string]bool{
"DCIM/100MSDCF/DSC00001.jpg": true, "DCIM/100MSDCF/DSC00001.jpg": true,
"DCIM/100MSDCF/DSC00001.ARW": true, "DCIM/100MSDCF/DSC00001.ARW": true,
"DCIM/100MSDCF/DSC00001.TIF": true,
"DCIM/100MSDCF/DSC00001.tif": true,
"PRIVATE/M4ROOT/CLIP/C0001.MP4": false, "PRIVATE/M4ROOT/CLIP/C0001.MP4": false,
"DCIM/100MSDCF/notes.txt": false, "DCIM/100MSDCF/notes.txt": false,
"DCIM/100MSDCF/noextension": false, "DCIM/100MSDCF/noextension": false,
+30 -24
View File
@@ -1,9 +1,8 @@
package dcf_test package dcf_test
import ( import (
"context" "os"
"errors" "path/filepath"
"io/fs"
"testing" "testing"
"sneak.berlin/go/dcf/pkg/dcf" "sneak.berlin/go/dcf/pkg/dcf"
@@ -126,30 +125,37 @@ func TestVideoString(t *testing.T) {
} }
} }
func TestObjectHashOfObjectBuiltByHandReturnsError(t *testing.T) { func TestObjectHashIsSHA256OfFile(t *testing.T) {
t.Parallel() t.Parallel()
object := dcf.Object{DCFStoreRoot: cardRoot, Path: "a.jpg"} root := t.TempDir()
err := os.WriteFile(filepath.Join(root, "a.jpg"), []byte("abc"), 0o600)
if err != nil {
t.Fatalf("WriteFile: %v", err)
}
object := dcf.Object{DCFStoreRoot: root, Path: "a.jpg"}
got, err := object.Hash()
if err != nil {
t.Fatalf("Hash() returned error: %v", err)
}
// The published SHA-256 test vector for "abc".
want := "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad"
if got != want {
t.Fatalf("Hash() = %q, want %q", got, want)
}
}
func TestObjectHashOfMissingFileReturnsError(t *testing.T) {
t.Parallel()
object := dcf.Object{DCFStoreRoot: t.TempDir(), Path: "absent.jpg"}
_, err := object.Hash() _, err := object.Hash()
if !errors.Is(err, fs.ErrInvalid) { if err == nil {
t.Fatalf("Hash() error = %v, want %v", err, fs.ErrInvalid) t.Fatal("Hash() of a missing file returned no error")
}
}
func TestGetStoresAndGetStoreReturnCancellation(t *testing.T) {
t.Parallel()
ctx, cancel := context.WithCancel(t.Context())
cancel()
_, err := dcf.GetStores(ctx)
if !errors.Is(err, context.Canceled) {
t.Errorf("GetStores error = %v, want %v", err, context.Canceled)
}
_, err = dcf.GetStore(ctx)
if !errors.Is(err, context.Canceled) {
t.Errorf("GetStore error = %v, want %v", err, context.Canceled)
} }
} }
-67
View File
@@ -1,67 +0,0 @@
package dcf
import (
"cmp"
"path"
"slices"
"strings"
)
// newFileGroups sorts the objects of one DCF directory into file groups, in file
// number order. Section 4.3.2.2 of the specification says the files in a DCF
// directory that share a file number are one group. Their first four characters
// and their extensions can differ.
func newFileGroups(objects []*Object) []*FileGroup {
var groups []*FileGroup
groupWithNumber := map[int]*FileGroup{}
for _, object := range objects {
group, found := groupWithNumber[object.FileNumber]
if !found {
group = &FileGroup{
DirectoryNumber: object.DirectoryNumber,
FileNumber: object.FileNumber,
}
groupWithNumber[object.FileNumber] = group
groups = append(groups, group)
}
group.Members = append(group.Members, object)
}
for _, group := range groups {
group.Primary = primaryMember(group.Members)
}
slices.SortFunc(groups, func(a, b *FileGroup) int {
return cmp.Compare(a.FileNumber, b.FileNumber)
})
return groups
}
// primaryMember returns the member that stands for the whole group. Section
// 6.2.1.3 of the specification gives a DCF basic file priority over a DCF extended
// image file. It says nothing about a DCF optional file, the other kind of JPG, so
// ranking every JPG first is a choice. Section 4.6 makes a THM only the thumbnail
// of another file, so it comes last. The specification leaves the rest open, so an
// image comes before a video, and a video before any other file. Among equals, the
// first in name order wins.
func primaryMember(members []*Object) *Object {
choices := []func(string) bool{isJPG, isImageFile, isVideoFile, isNotTHM}
for _, choice := range choices {
for _, member := range members {
if choice(member.Path) {
return member
}
}
}
return members[0]
}
func isNotTHM(name string) bool {
return !strings.EqualFold(path.Ext(name), ".THM")
}
-232
View File
@@ -1,232 +0,0 @@
package dcf
import (
"slices"
"testing"
"testing/fstest"
)
// mustFileGroups returns the file groups of directory, and stops the test unless
// there are exactly count of them.
func mustFileGroups(t *testing.T, directory *Directory, count int) []*FileGroup {
t.Helper()
if len(directory.FileGroups) != count {
t.Fatalf("%s has %d file groups, want %d",
directory.Name, len(directory.FileGroups), count)
}
return directory.FileGroups
}
// assertFileGroup checks the numbers, the primary member and the members of group.
func assertFileGroup(
t *testing.T,
group *FileGroup,
directoryNumber, fileNumber int,
primary string,
members ...string,
) {
t.Helper()
if group.DirectoryNumber != directoryNumber || group.FileNumber != fileNumber {
t.Errorf("group numbers = %d and %d, want %d and %d",
group.DirectoryNumber, group.FileNumber, directoryNumber, fileNumber)
}
if group.Primary.Path != primary {
t.Errorf("Primary = %q, want %q", group.Primary.Path, primary)
}
paths := make([]string, 0, len(group.Members))
for _, member := range group.Members {
paths = append(paths, member.Path)
}
assertPaths(t, "members", paths, members...)
if got := group.MembersCount(); got != len(members) {
t.Errorf("MembersCount() = %d, want %d", got, len(members))
}
}
// assertSizes checks the total size of group and the size of each member.
func assertSizes(t *testing.T, group *FileGroup, total int64, sizes ...int64) {
t.Helper()
if got := group.TotalSize(); got != total {
t.Errorf("TotalSize() = %d, want %d", got, total)
}
got := make([]int64, 0, len(group.Members))
for _, member := range group.Members {
got = append(got, member.Size)
}
if !slices.Equal(got, sizes) {
t.Errorf("member sizes = %v, want %v", got, sizes)
}
}
// assertPrimaryMember checks which of the named members primaryMember picks.
func assertPrimaryMember(t *testing.T, want string, names ...string) {
t.Helper()
members := make([]*Object, 0, len(names))
for _, name := range names {
members = append(members, &Object{Path: name})
}
if got := primaryMember(members).Path; got != want {
t.Errorf("primaryMember of %q = %q, want %q", names, got, want)
}
}
func TestParseStoreGroupsFilesSharingAFileNumber(t *testing.T) {
t.Parallel()
store := mustParseStore(t, fstest.MapFS{
"DCIM/100ABCDE/DSC00001.ARW": {Data: make([]byte, 2000)},
"DCIM/100ABCDE/DSC00001.JPG": {Data: make([]byte, 500)},
"DCIM/100ABCDE/MVI_0002.MP4": {Data: make([]byte, 9000)},
"DCIM/100ABCDE/MVI_0002.THM": {Data: make([]byte, 100)},
"DCIM/100ABCDE/DSC00003.JPG": {Data: make([]byte, 700)},
"DCIM/101ABCDE/DSC00001.JPG": {Data: make([]byte, 300)},
})
first := mustFileGroups(t, store.Directories[0], 3)
second := mustFileGroups(t, store.Directories[1], 1)
assertFileGroup(t, first[0], 100, 1, "DCIM/100ABCDE/DSC00001.JPG",
"DCIM/100ABCDE/DSC00001.ARW",
"DCIM/100ABCDE/DSC00001.JPG",
)
assertSizes(t, first[0], 2500, 2000, 500)
assertFileGroup(t, first[1], 100, 2, "DCIM/100ABCDE/MVI_0002.MP4",
"DCIM/100ABCDE/MVI_0002.MP4",
"DCIM/100ABCDE/MVI_0002.THM",
)
assertSizes(t, first[1], 9100, 9000, 100)
assertFileGroup(t, first[2], 100, 3, "DCIM/100ABCDE/DSC00003.JPG",
"DCIM/100ABCDE/DSC00003.JPG",
)
assertSizes(t, first[2], 700, 700)
assertFileGroup(t, second[0], 101, 1, "DCIM/101ABCDE/DSC00001.JPG",
"DCIM/101ABCDE/DSC00001.JPG",
)
assertSizes(t, second[0], 300, 300)
// The images, the videos and the totals still count each file on its own.
if got := store.ImagesCount(); got != 4 {
t.Errorf("ImagesCount() = %d, want 4", got)
}
if got := store.VideosCount(); got != 1 {
t.Errorf("VideosCount() = %d, want 1", got)
}
if got := store.TotalSize(); got != 12500 {
t.Errorf("TotalSize() = %d, want 12500", got)
}
}
func TestParseStoreGroupsFilesWhoseFirstFourCharactersDiffer(t *testing.T) {
t.Parallel()
store := mustParseStore(t, newCard(
"DCIM/100ABCDE/ABCD0004.TIF",
"DCIM/100ABCDE/THMB0004.THM",
))
groups := mustFileGroups(t, store.Directories[0], 1)
assertFileGroup(t, groups[0], 100, 4, "DCIM/100ABCDE/ABCD0004.TIF",
"DCIM/100ABCDE/ABCD0004.TIF",
"DCIM/100ABCDE/THMB0004.THM",
)
}
func TestParseStoreOrdersFileGroupsByFileNumber(t *testing.T) {
t.Parallel()
store := mustParseStore(t, newCard(
"DCIM/100ABCDE/ABCD0002.JPG",
"DCIM/100ABCDE/WXYZ0001.JPG",
))
groups := mustFileGroups(t, store.Directories[0], 2)
assertFileGroup(t, groups[0], 100, 1, "DCIM/100ABCDE/WXYZ0001.JPG",
"DCIM/100ABCDE/WXYZ0001.JPG",
)
assertFileGroup(t, groups[1], 100, 2, "DCIM/100ABCDE/ABCD0002.JPG",
"DCIM/100ABCDE/ABCD0002.JPG",
)
}
func TestParseStoreLeavesOtherFilesOutOfFileGroups(t *testing.T) {
t.Parallel()
store := mustParseStore(t, newCard(
"DCIM/100ABCDE/ABCD0004.ARW",
"DCIM/100ABCDE/ABCD0004.JPG",
"DCIM/100ABCDE/PQRS0004.JPG",
"DCIM/100ABCDE/README.TXT",
"DCIM/MISC/ABCD0005.JPG",
"PRIVATE/M4ROOT/CLIP/C0006.MP4",
))
if len(store.Directories) != 1 {
t.Fatalf("got %d directories, want 1", len(store.Directories))
}
groups := mustFileGroups(t, store.Directories[0], 1)
assertFileGroup(t, groups[0], 100, 4, "DCIM/100ABCDE/ABCD0004.ARW",
"DCIM/100ABCDE/ABCD0004.ARW",
)
}
func TestParseStoreGivesDirectoryWithoutDCFFilesNoFileGroups(t *testing.T) {
t.Parallel()
store := mustParseStore(t, newCard("DCIM/100ABCDE/README.TXT"))
mustFileGroups(t, store.Directories[0], 0)
}
func TestPrimaryMember(t *testing.T) {
t.Parallel()
// A JPG comes first, in either case.
assertPrimaryMember(t, "A0010001.JPG", "A0010001.ARW", "A0010001.JPG")
assertPrimaryMember(t, "A0010001.JPG", "A0010001.JPG", "A0010001.MOV")
assertPrimaryMember(t, "a0010001.jpg", "a0010001.arw", "a0010001.jpg")
// Then any other image.
assertPrimaryMember(t, "Z0010001.ARW", "A0010001.MP4", "Z0010001.ARW")
assertPrimaryMember(t, "Z0010001.ARW", "A0010001.WAV", "Z0010001.ARW")
assertPrimaryMember(t, "A0010001.TIF", "A0010001.MOV", "A0010001.TIF")
assertPrimaryMember(t, "Z0010001.TIF", "A0010001.WAV", "Z0010001.TIF")
// Then a video.
assertPrimaryMember(t, "A0010001.MP4", "A0010001.AAC", "A0010001.MP4")
assertPrimaryMember(t, "Z0010001.MP4", "A0010001.THM", "Z0010001.MP4")
// Then any other file except a THM, in either case.
assertPrimaryMember(t, "Z0010001.WAV", "A0010001.THM", "Z0010001.WAV")
assertPrimaryMember(t, "a0010001.wav", "a0010001.thm", "a0010001.wav")
// A THM is primary only when the group holds nothing else.
assertPrimaryMember(t, "A0010001.THM", "A0010001.THM")
// Among equals, the first in name order wins.
assertPrimaryMember(t, "A0010001.THM", "A0010001.THM", "Z0010001.THM")
assertPrimaryMember(t, "A0010001.WAV", "A0010001.WAV", "Z0010001.XML")
}
+21 -22
View File
@@ -1,28 +1,21 @@
package dcf package dcf
import ( import (
"context"
"fmt" "fmt"
"log/slog" "log/slog"
"os" "os"
"path/filepath"
"slices" "slices"
"github.com/shirou/gopsutil/disk" "github.com/shirou/gopsutil/disk"
) )
func findAllMountPoints(ctx context.Context) ([]string, error) { func findAllMountPoints() ([]string, error) {
mountpoints := []string{} mountpoints := []string{}
// disk.PartitionsWithContext does not check ctx on Linux. partitions, err := disk.Partitions(false) // physical devices only, so false
err := ctx.Err()
if err != nil { if err != nil {
return nil, fmt.Errorf("listing the mounted filesystems: %w", err) return nil, err
}
// physical devices only, so false
partitions, err := disk.PartitionsWithContext(ctx, false)
if err != nil {
return nil, fmt.Errorf("listing the mounted filesystems: %w", err)
} }
for _, partition := range partitions { for _, partition := range partitions {
@@ -34,18 +27,21 @@ func findAllMountPoints(ctx context.Context) ([]string, error) {
return mountpoints, nil return mountpoints, nil
} }
// findDCFMountPoints returns the paths of the mountpoints that hold a DCF store. // findDCFMountPoints returns a list of strings of the paths of mountpoints
func findDCFMountPoints(ctx context.Context) ([]string, error) { // that hold a DCF store. Its only argument is an integer of how many mountpoints
mountpoints, err := findAllMountPoints(ctx) // to return. If the argument is 0, all mountpoints are returned.
func findDCFMountPoints(requestedCount int) ([]string, error) {
mountpoints, err := findAllMountPoints()
if err != nil { if err != nil {
return nil, err return nil, err
} }
return filterDCFMountPoints(mountpoints), nil return filterDCFMountPoints(mountpoints, requestedCount), nil
} }
// filterDCFMountPoints returns the mountpoints that hold a DCF store. // filterDCFMountPoints returns the first requestedCount of the mountpoints that
func filterDCFMountPoints(mountpoints []string) []string { // hold a DCF store. If requestedCount is 0, it returns all of them.
func filterDCFMountPoints(mountpoints []string, requestedCount int) []string {
filteredMountpoints := []string{} filteredMountpoints := []string{}
for _, mountpoint := range mountpoints { for _, mountpoint := range mountpoints {
@@ -56,6 +52,9 @@ func filterDCFMountPoints(mountpoints []string) []string {
slog.Debug(fmt.Sprintf("Keeping mountpoint %s\n", mountpoint)) slog.Debug(fmt.Sprintf("Keeping mountpoint %s\n", mountpoint))
filteredMountpoints = append(filteredMountpoints, mountpoint) filteredMountpoints = append(filteredMountpoints, mountpoint)
if (requestedCount > 0) && (len(filteredMountpoints) >= requestedCount) {
break
}
} }
return filteredMountpoints return filteredMountpoints
@@ -64,12 +63,12 @@ func filterDCFMountPoints(mountpoints []string) []string {
// isDCFMountPoint reports whether the root of mountpoint holds a DCIM or // isDCFMountPoint reports whether the root of mountpoint holds a DCIM or
// PRIVATE/M4ROOT directory, which is how we detect DCF stores. // PRIVATE/M4ROOT directory, which is how we detect DCF stores.
func isDCFMountPoint(mountpoint string) bool { func isDCFMountPoint(mountpoint string) bool {
fsys := os.DirFS(mountpoint)
for _, directory := range []string{dcimDirectory, m4RootDirectory} { for _, directory := range []string{dcimDirectory, m4RootDirectory} {
found, err := hasDirectory(fsys, directory) path := filepath.Join(mountpoint, directory)
if err == nil && found {
slog.Debug(fmt.Sprintf("Found %s directory at %s\n", directory, mountpoint)) info, err := os.Stat(path)
if err == nil && info.IsDir() {
slog.Debug(fmt.Sprintf("Found %s directory at %s\n", directory, path))
return true return true
} }
+17 -19
View File
@@ -57,31 +57,29 @@ func TestIsDCFMountPointIgnoresPlainFile(t *testing.T) {
} }
} }
func TestIsDCFMountPointIgnoresSymlink(t *testing.T) { func TestFilterDCFMountPointsReturnsRequestedCount(t *testing.T) {
t.Parallel()
outside := newMountPoint(t, "M4ROOT")
for _, link := range []string{dcimDirectory, privateDirectory, m4RootDirectory} {
root := newMountPoint(t, filepath.Dir(link))
addSymlink(t, root, link, outside)
if isDCFMountPoint(root) {
t.Errorf("isDCFMountPoint counted a symlink at %s as a directory", link)
}
}
}
func TestFilterDCFMountPointsKeepsThoseHoldingAStore(t *testing.T) {
t.Parallel() t.Parallel()
first := newMountPoint(t, "DCIM") first := newMountPoint(t, "DCIM")
second := newMountPoint(t, "PRIVATE/M4ROOT") second := newMountPoint(t, "PRIVATE/M4ROOT")
third := newMountPoint(t, "DCIM")
got := filterDCFMountPoints([]string{newMountPoint(t), first, second}) // This one holds no DCF store, so it must not use up the requested count.
mountpoints := []string{newMountPoint(t), first, second, third}
want := []string{first, second} cases := map[int][]string{
0: {first, second, third},
1: {first},
2: {first, second},
3: {first, second, third},
4: {first, second, third},
}
for requestedCount, want := range cases {
got := filterDCFMountPoints(mountpoints, requestedCount)
if !slices.Equal(got, want) { if !slices.Equal(got, want) {
t.Errorf("filterDCFMountPoints = %v, want %v", got, want) t.Errorf("filterDCFMountPoints(%d) = %v, want %v",
requestedCount, got, want)
}
} }
} }
+56 -117
View File
@@ -1,14 +1,13 @@
package dcf package dcf
import ( import (
"context"
"errors" "errors"
"fmt"
"io/fs" "io/fs"
"path" "path"
"regexp" "regexp"
"strconv" "strconv"
"strings" "strings"
"syscall"
) )
// The two directories at the root of a card that hold its files. // The two directories at the root of a card that hold its files.
@@ -27,38 +26,43 @@ var (
// parseStore reads one card from fsys, which is rooted at the card's mount // parseStore reads one card from fsys, which is rooted at the card's mount
// point. The root argument is the path of that mount point. Only DCIM and // point. The root argument is the path of that mount point. Only DCIM and
// PRIVATE/M4ROOT are read. A card can lack either one. A symlink is never // PRIVATE/M4ROOT are read. A card can lack either one.
// followed. An error names the mount point, because fsys reports only paths func parseStore(fsys fs.FS, root string) (*Store, error) {
// inside it. When ctx is cancelled, the read stops before the next directory.
func parseStore(ctx context.Context, fsys fs.FS, root string) (*Store, error) {
store := &Store{RootDirectory: root} store := &Store{RootDirectory: root}
err := parseDCIM(ctx, fsys, store) hasDCIM, err := hasDirectory(fsys, dcimDirectory)
if err == nil { if err != nil {
err = parseM4Root(ctx, fsys, store) return nil, err
} }
if hasDCIM {
err = parseDCIM(fsys, store)
if err != nil { if err != nil {
return nil, fmt.Errorf("reading the card at %s: %w", root, err) return nil, err
}
}
hasM4Root, err := hasDirectory(fsys, m4RootDirectory)
if err != nil {
return nil, err
}
if hasM4Root {
err = parseM4Root(fsys, store)
if err != nil {
return nil, err
}
} }
return store, nil return store, nil
} }
// hasDirectory reports whether the card holds the named directory. A plain file // hasDirectory reports whether the card holds the named directory. Only a
// or a symlink at any step of the path counts as missing. Any other failure to // missing directory counts as false: any other failure to look is an error. A
// look is an error. // plain file named PRIVATE makes a lookup of PRIVATE/M4ROOT fail with ENOTDIR.
func hasDirectory(fsys fs.FS, name string) (bool, error) { func hasDirectory(fsys fs.FS, name string) (bool, error) {
parent := path.Dir(name) info, err := fs.Stat(fsys, name)
if parent != "." { if errors.Is(err, fs.ErrNotExist) || errors.Is(err, syscall.ENOTDIR) {
found, err := hasDirectory(fsys, parent)
if err != nil || !found {
return false, err
}
}
info, err := fs.Lstat(fsys, name)
if errors.Is(err, fs.ErrNotExist) {
return false, nil return false, nil
} }
@@ -69,26 +73,11 @@ func hasDirectory(fsys fs.FS, name string) (bool, error) {
return info.IsDir(), nil return info.IsDir(), nil
} }
// readDir returns the entries of the named directory, unless ctx is cancelled.
func readDir(ctx context.Context, fsys fs.FS, name string) ([]fs.DirEntry, error) {
err := ctx.Err()
if err != nil {
return nil, err
}
return fs.ReadDir(fsys, name)
}
// parseDCIM adds the DCF directories under DCIM to store. Everything else there // parseDCIM adds the DCF directories under DCIM to store. Everything else there
// is not conforming. That includes directories that share a directory number, // is not conforming. That includes directories that share a directory number,
// which section 7.1.2 of the specification treats as non-DCF directories. // which section 7.1.2 of the specification treats as non-DCF directories.
func parseDCIM(ctx context.Context, fsys fs.FS, store *Store) error { func parseDCIM(fsys fs.FS, store *Store) error {
found, err := hasDirectory(fsys, dcimDirectory) entries, err := fs.ReadDir(fsys, dcimDirectory)
if err != nil || !found {
return err
}
entries, err := readDir(ctx, fsys, dcimDirectory)
if err != nil { if err != nil {
return err return err
} }
@@ -105,9 +94,10 @@ func parseDCIM(ctx context.Context, fsys fs.FS, store *Store) error {
number := parseNumber(dcfDirectoryName, entry.Name()) number := parseNumber(dcfDirectoryName, entry.Name())
if entry.IsDir() && number != 0 && directoriesWithNumber[number] == 1 { if entry.IsDir() && number != 0 && directoriesWithNumber[number] == 1 {
err = parseDirectory(ctx, fsys, store, entry.Name(), number) err = parseDirectory(fsys, store, entry.Name(), number)
} else { } else {
err = addNonConforming(ctx, fsys, store, dcimDirectory, entry) entryPath := path.Join(dcimDirectory, entry.Name())
err = addNonConforming(fsys, store, entryPath)
} }
if err != nil { if err != nil {
@@ -119,20 +109,13 @@ func parseDCIM(ctx context.Context, fsys fs.FS, store *Store) error {
} }
// parseDirectory adds one DCF directory and the DCF files directly inside it to // parseDirectory adds one DCF directory and the DCF files directly inside it to
// store. A DCF file is a regular file, so a symlink with a DCF name is not one. // store. Everything else there is not conforming. That includes JPG files that
// Everything else there is not conforming. That includes JPG files that share a // share a file number, which section 7.2.2 of the specification says must not
// file number, which section 7.2.2 of the specification says must not show DCF // show DCF numbers. Other files that share a file number do conform.
// numbers. Other files that share a file number do conform. func parseDirectory(fsys fs.FS, store *Store, name string, number int) error {
func parseDirectory(
ctx context.Context,
fsys fs.FS,
store *Store,
name string,
number int,
) error {
directoryPath := path.Join(dcimDirectory, name) directoryPath := path.Join(dcimDirectory, name)
entries, err := readDir(ctx, fsys, directoryPath) entries, err := fs.ReadDir(fsys, directoryPath)
if err != nil { if err != nil {
return err return err
} }
@@ -140,7 +123,7 @@ func parseDirectory(
jpgsWithNumber := map[int]int{} jpgsWithNumber := map[int]int{}
for _, entry := range entries { for _, entry := range entries {
if entry.Type().IsRegular() && isJPG(entry.Name()) { if !entry.IsDir() && isJPG(entry.Name()) {
jpgsWithNumber[parseNumber(dcfFileName, entry.Name())]++ jpgsWithNumber[parseNumber(dcfFileName, entry.Name())]++
} }
} }
@@ -148,11 +131,12 @@ func parseDirectory(
directory := &Directory{Number: number, Name: name} directory := &Directory{Number: number, Name: name}
for _, entry := range entries { for _, entry := range entries {
entryPath := path.Join(directoryPath, entry.Name())
fileNumber := parseNumber(dcfFileName, entry.Name()) fileNumber := parseNumber(dcfFileName, entry.Name())
sharedJPG := isJPG(entry.Name()) && jpgsWithNumber[fileNumber] > 1 sharedJPG := isJPG(entry.Name()) && jpgsWithNumber[fileNumber] > 1
if !entry.Type().IsRegular() || fileNumber == 0 || sharedJPG { if entry.IsDir() || fileNumber == 0 || sharedJPG {
err = addNonConforming(ctx, fsys, store, directoryPath, entry) err = addNonConforming(fsys, store, entryPath)
if err != nil { if err != nil {
return err return err
} }
@@ -160,9 +144,7 @@ func parseDirectory(
continue continue
} }
entryPath := path.Join(directoryPath, entry.Name()) object, err := newObject(store, entryPath, entry)
object, err := newObject(fsys, store, entryPath, entry)
if err != nil { if err != nil {
return err return err
} }
@@ -174,44 +156,25 @@ func parseDirectory(
addMedia(store, object) addMedia(store, object)
} }
directory.FileGroups = newFileGroups(directory.Objects)
store.Directories = append(store.Directories, directory) store.Directories = append(store.Directories, directory)
return nil return nil
} }
// parseM4Root adds the images and videos under PRIVATE/M4ROOT to store. The DCF // parseM4Root adds the images and videos under PRIVATE/M4ROOT to store. The DCF
// naming rules do not apply there, so these files have no DCF numbers. It skips // naming rules do not apply there, so these files have no DCF numbers.
// a symlink. It also skips a name that is not valid UTF-8, because fs.FS refuses func parseM4Root(fsys fs.FS, store *Store) error {
// to open one.
func parseM4Root(ctx context.Context, fsys fs.FS, store *Store) error {
found, err := hasDirectory(fsys, m4RootDirectory)
if err != nil || !found {
return err
}
return fs.WalkDir(fsys, m4RootDirectory, return fs.WalkDir(fsys, m4RootDirectory,
func(filePath string, entry fs.DirEntry, err error) error { func(filePath string, entry fs.DirEntry, err error) error {
if err != nil { if err != nil {
return err return err
} }
validName := fs.ValidPath(filePath)
if entry.IsDir() && !validName {
return fs.SkipDir
}
// fs.WalkDir reads a directory right after this call for it.
if entry.IsDir() { if entry.IsDir() {
return ctx.Err()
}
if !entry.Type().IsRegular() || !validName {
return nil return nil
} }
object, err := newObject(fsys, store, filePath, entry) object, err := newObject(store, filePath, entry)
if err != nil { if err != nil {
return err return err
} }
@@ -222,46 +185,23 @@ func parseM4Root(ctx context.Context, fsys fs.FS, store *Store) error {
}) })
} }
// addNonConforming lists one entry of the directory dir on store as not // addNonConforming lists the named file or directory on store as not
// conforming. If the entry is a directory, it lists everything inside it too. It // conforming, along with everything inside it.
// does not follow a symlink. It does not read a directory whose name is not func addNonConforming(fsys fs.FS, store *Store, name string) error {
// valid UTF-8, because fs.FS refuses to open one. return fs.WalkDir(fsys, name,
func addNonConforming( func(entryPath string, _ fs.DirEntry, err error) error {
ctx context.Context, if err != nil {
fsys fs.FS, return err
store *Store, }
dir string,
entry fs.DirEntry,
) error {
entryPath := path.Join(dir, entry.Name())
store.NonConforming = append(store.NonConforming, entryPath) store.NonConforming = append(store.NonConforming, entryPath)
if !entry.IsDir() || !fs.ValidPath(entryPath) {
return nil
}
entries, err := readDir(ctx, fsys, entryPath)
if err != nil {
return err
}
for _, inner := range entries {
err = addNonConforming(ctx, fsys, store, entryPath, inner)
if err != nil {
return err
}
}
return nil return nil
})
} }
// newObject returns the Object for the file at filePath, with no DCF numbers. // newObject returns the Object for the file at filePath, with no DCF numbers.
func newObject( func newObject(store *Store, filePath string, entry fs.DirEntry) (Object, error) {
fsys fs.FS,
store *Store,
filePath string,
entry fs.DirEntry,
) (Object, error) {
info, err := entry.Info() info, err := entry.Info()
if err != nil { if err != nil {
return Object{}, err return Object{}, err
@@ -272,7 +212,6 @@ func newObject(
Path: filePath, Path: filePath,
Size: info.Size(), Size: info.Size(),
Extension: path.Ext(filePath), Extension: path.Ext(filePath),
fsys: fsys,
}, nil }, nil
} }
+39 -459
View File
@@ -1,11 +1,9 @@
package dcf package dcf
import ( import (
"context"
"errors" "errors"
"io/fs" "io/fs"
"os" "os"
"path"
"path/filepath" "path/filepath"
"reflect" "reflect"
"slices" "slices"
@@ -13,10 +11,7 @@ import (
"testing/fstest" "testing/fstest"
) )
const ( const testRoot = "/mnt/card"
testRoot = "/mnt/card"
privateDirectory = "PRIVATE"
)
// newCard returns a card holding an empty file at each of the given paths. // newCard returns a card holding an empty file at each of the given paths.
func newCard(paths ...string) fstest.MapFS { func newCard(paths ...string) fstest.MapFS {
@@ -29,44 +24,10 @@ func newCard(paths ...string) fstest.MapFS {
return card return card
} }
// newCardOnDisk returns a temporary directory holding the three bytes "abc" in a
// file at each of the given paths. It is for tests that fstest.MapFS cannot do.
func newCardOnDisk(t *testing.T, paths ...string) string {
t.Helper()
root := t.TempDir()
for _, filePath := range paths {
fullPath := filepath.Join(root, filePath)
err := os.MkdirAll(filepath.Dir(fullPath), 0o750)
if err != nil {
t.Fatalf("MkdirAll: %v", err)
}
err = os.WriteFile(fullPath, []byte("abc"), 0o600)
if err != nil {
t.Fatalf("WriteFile: %v", err)
}
}
return root
}
// addSymlink creates a symlink to target at the named path under root.
func addSymlink(t *testing.T, root, name, target string) {
t.Helper()
err := os.Symlink(target, filepath.Join(root, name))
if err != nil {
t.Fatalf("Symlink: %v", err)
}
}
func mustParseStore(t *testing.T, fsys fs.FS) *Store { func mustParseStore(t *testing.T, fsys fs.FS) *Store {
t.Helper() t.Helper()
store, err := parseStore(t.Context(), fsys, testRoot) store, err := parseStore(fsys, testRoot)
if err != nil { if err != nil {
t.Fatalf("parseStore: %v", err) t.Fatalf("parseStore: %v", err)
} }
@@ -114,16 +75,6 @@ func directoryNumbers(store *Store) []int {
return numbers return numbers
} }
// wantDirectory returns the Directory that parseStore should build for objects.
func wantDirectory(number int, name string, objects ...*Object) *Directory {
return &Directory{
Number: number,
Name: name,
Objects: objects,
FileGroups: newFileGroups(objects),
}
}
func assertPaths(t *testing.T, what string, got []string, want ...string) { func assertPaths(t *testing.T, what string, got []string, want ...string) {
t.Helper() t.Helper()
@@ -158,7 +109,6 @@ func TestParseStorePutsEachFileWhereItBelongs(t *testing.T) {
Extension: ".ARW", Extension: ".ARW",
DirectoryNumber: 100, DirectoryNumber: 100,
FileNumber: 1, FileNumber: 1,
fsys: card,
} }
mp4 := Object{ mp4 := Object{
DCFStoreRoot: testRoot, DCFStoreRoot: testRoot,
@@ -167,7 +117,6 @@ func TestParseStorePutsEachFileWhereItBelongs(t *testing.T) {
Extension: ".MP4", Extension: ".MP4",
DirectoryNumber: 101, DirectoryNumber: 101,
FileNumber: 2, FileNumber: 2,
fsys: card,
} }
thm := Object{ thm := Object{
DCFStoreRoot: testRoot, DCFStoreRoot: testRoot,
@@ -176,19 +125,17 @@ func TestParseStorePutsEachFileWhereItBelongs(t *testing.T) {
Extension: ".THM", Extension: ".THM",
DirectoryNumber: 101, DirectoryNumber: 101,
FileNumber: 2, FileNumber: 2,
fsys: card,
} }
clip := Object{ clip := Object{
DCFStoreRoot: testRoot, DCFStoreRoot: testRoot,
Path: "PRIVATE/M4ROOT/CLIP/C0002.MP4", Path: "PRIVATE/M4ROOT/CLIP/C0002.MP4",
Size: 400, Size: 400,
Extension: ".MP4", Extension: ".MP4",
fsys: card,
} }
wantDirectories := []*Directory{ wantDirectories := []*Directory{
wantDirectory(100, "100MSDCF", &arw), {Number: 100, Name: "100MSDCF", Objects: []*Object{&arw}},
wantDirectory(101, "101CANON", &mp4, &thm), {Number: 101, Name: "101CANON", Objects: []*Object{&mp4, &thm}},
} }
if !reflect.DeepEqual(store.Directories, wantDirectories) { if !reflect.DeepEqual(store.Directories, wantDirectories) {
t.Errorf("Directories = %+v, want %+v", store.Directories, wantDirectories) t.Errorf("Directories = %+v, want %+v", store.Directories, wantDirectories)
@@ -269,8 +216,6 @@ func TestParseNumberOfDirectoryName(t *testing.T) {
"100ABC-E": 0, "100ABC-E": 0,
"100ABCD": 0, "100ABCD": 0,
"100ABCDEF": 0, "100ABCDEF": 0,
"1000ABCDE": 0,
"X100ABCDE": 0,
"TEMP": 0, "TEMP": 0,
} }
@@ -294,14 +239,12 @@ func TestParseNumberOfFileName(t *testing.T) {
"ABCD0000.JPG": 0, "ABCD0000.JPG": 0,
"A0004.JPG": 0, "A0004.JPG": 0,
"ABCDE0005.JPG": 0, "ABCDE0005.JPG": 0,
"ABCD10000.JPG": 0,
"abcdefg0006.JPG": 0, "abcdefg0006.JPG": 0,
"+-@]0007.JPG": 0, "+-@]0007.JPG": 0,
"ABCD00~1.JPG": 0, "ABCD00~1.JPG": 0,
"ABCDEFGH.JPG": 0, "ABCDEFGH.JPG": 0,
"ABCD0001": 0, "ABCD0001": 0,
"ABCD0001.": 0, "ABCD0001.": 0,
"ABCD0001.JP": 0,
"ABCD0001.JPEG": 0, "ABCD0001.JPEG": 0,
"ABCD0001.J-G": 0, "ABCD0001.J-G": 0,
"._DSC0001.JPG": 0, "._DSC0001.JPG": 0,
@@ -318,18 +261,19 @@ func TestParseNumberOfFileName(t *testing.T) {
func TestParseStoreTreatsLowerCaseNamesAsDCFNames(t *testing.T) { func TestParseStoreTreatsLowerCaseNamesAsDCFNames(t *testing.T) {
t.Parallel() t.Parallel()
card := newCard("DCIM/101abcde/abcd0002.jpg") store := mustParseStore(t, newCard("DCIM/101abcde/abcd0002.jpg"))
store := mustParseStore(t, card) want := []*Directory{{
Number: 101,
want := []*Directory{wantDirectory(101, "101abcde", &Object{ Name: "101abcde",
Objects: []*Object{{
DCFStoreRoot: testRoot, DCFStoreRoot: testRoot,
Path: "DCIM/101abcde/abcd0002.jpg", Path: "DCIM/101abcde/abcd0002.jpg",
Extension: ".jpg", Extension: ".jpg",
DirectoryNumber: 101, DirectoryNumber: 101,
FileNumber: 2, FileNumber: 2,
fsys: card, }},
})} }}
if !reflect.DeepEqual(store.Directories, want) { if !reflect.DeepEqual(store.Directories, want) {
t.Errorf("Directories = %+v, want %+v", store.Directories, want) t.Errorf("Directories = %+v, want %+v", store.Directories, want)
} }
@@ -428,49 +372,6 @@ func TestParseStoreKeepsOtherFilesSharingAFileNumber(t *testing.T) {
assertPaths(t, "NonConforming", store.NonConforming) assertPaths(t, "NonConforming", store.NonConforming)
} }
func TestParseStoreKeepsARWSharingANumberWithTwoJPGs(t *testing.T) {
t.Parallel()
store := mustParseStore(t, newCard(
"DCIM/100ABCDE/ABCD0004.ARW",
"DCIM/100ABCDE/ABCD0004.JPG",
"DCIM/100ABCDE/PQRS0004.JPG",
))
assertPaths(t, "objects", objectPaths(store.Directories[0]),
"DCIM/100ABCDE/ABCD0004.ARW",
)
assertPaths(t, "NonConforming", store.NonConforming,
"DCIM/100ABCDE/ABCD0004.JPG",
"DCIM/100ABCDE/PQRS0004.JPG",
)
}
func TestParseStoreDoesNotCountDirectoryAsSharingAFileNumber(t *testing.T) {
t.Parallel()
store := mustParseStore(t, newCard(
"DCIM/100ABCDE/ABCD0003.JPG",
"DCIM/100ABCDE/PQRS0003.JPG/README.TXT",
))
assertPaths(t, "objects", objectPaths(store.Directories[0]),
"DCIM/100ABCDE/ABCD0003.JPG",
)
assertPaths(t, "NonConforming", store.NonConforming,
"DCIM/100ABCDE/PQRS0003.JPG",
"DCIM/100ABCDE/PQRS0003.JPG/README.TXT",
)
}
func TestParseStoreDoesNotCountDirectoryNamedLikeAVideo(t *testing.T) {
t.Parallel()
store := mustParseStore(t, newCard("PRIVATE/M4ROOT/CLIP.MP4/C0001.XML"))
assertPaths(t, "Videos", videoPaths(store))
}
func TestParseStoreRejectsSubdirectoryOfDCFDirectory(t *testing.T) { func TestParseStoreRejectsSubdirectoryOfDCFDirectory(t *testing.T) {
t.Parallel() t.Parallel()
@@ -540,7 +441,24 @@ func TestParseStoreTreatsPlainFilesAsMissingDirectories(t *testing.T) {
func TestParseStoreReadsRealFilesystemWithPlainFileNamedPrivate(t *testing.T) { func TestParseStoreReadsRealFilesystemWithPlainFileNamedPrivate(t *testing.T) {
t.Parallel() t.Parallel()
root := newCardOnDisk(t, "DCIM/100ABCDE/ABCD0001.JPG", "PRIVATE") root := t.TempDir()
err := os.MkdirAll(filepath.Join(root, "DCIM", "100ABCDE"), 0o750)
if err != nil {
t.Fatalf("MkdirAll: %v", err)
}
jpg := filepath.Join(root, "DCIM", "100ABCDE", "ABCD0001.JPG")
err = os.WriteFile(jpg, []byte("abc"), 0o600)
if err != nil {
t.Fatalf("WriteFile: %v", err)
}
err = os.WriteFile(filepath.Join(root, "PRIVATE"), nil, 0o600)
if err != nil {
t.Fatalf("WriteFile: %v", err)
}
store := mustParseStore(t, os.DirFS(root)) store := mustParseStore(t, os.DirFS(root))
@@ -551,356 +469,18 @@ func TestParseStoreReadsRealFilesystemWithPlainFileNamedPrivate(t *testing.T) {
} }
} }
// A real filesystem can hold a name with the byte 0xff, which is not valid UTF-8. // unreadableFS fails every read the way a failing card does.
// fs.FS refuses to open such a name. type unreadableFS struct{}
func TestParseStoreListsNamesThatFSRefuses(t *testing.T) {
func (unreadableFS) Open(string) (fs.File, error) {
return nil, fs.ErrPermission
}
func TestParseStoreReturnsReadErrors(t *testing.T) {
t.Parallel() t.Parallel()
root := newCardOnDisk(t, _, err := parseStore(unreadableFS{}, testRoot)
"DCIM/100ABCDE/ABCD0001.JPG",
"DCIM/100ABCDE/A\xffDIR/INNER",
"DCIM/100ABCDE/A\xffFILE",
"DCIM/A\xffDIR/INNER",
"DCIM/A\xffFILE",
"DCIM/MISC/A\xffDIR/INNER",
"DCIM/MISC/A\xffFILE",
)
store := mustParseStore(t, os.DirFS(root))
assertPaths(t, "Images", imagePaths(store), "DCIM/100ABCDE/ABCD0001.JPG")
assertPaths(t, "NonConforming", store.NonConforming,
"DCIM/100ABCDE/A\xffDIR",
"DCIM/100ABCDE/A\xffFILE",
"DCIM/A\xffDIR",
"DCIM/A\xffFILE",
"DCIM/MISC",
"DCIM/MISC/A\xffDIR",
"DCIM/MISC/A\xffFILE",
)
}
func TestParseStoreListsDanglingSymlinks(t *testing.T) {
t.Parallel()
root := newCardOnDisk(t, "DCIM/100ABCDE/ABCD0001.JPG", "DCIM/MISC/README.TXT")
addSymlink(t, root, "DCIM/100ABCDE/ABCD0002.JPG", "absent")
addSymlink(t, root, "DCIM/100ABCDE/LINK", "absent")
addSymlink(t, root, "DCIM/101ABCDE", "absent")
addSymlink(t, root, "DCIM/LINK", "absent")
addSymlink(t, root, "DCIM/MISC/LINK", "absent")
store := mustParseStore(t, os.DirFS(root))
if got, want := directoryNumbers(store), []int{100}; !slices.Equal(got, want) {
t.Errorf("directory numbers = %v, want %v", got, want)
}
assertPaths(t, "Images", imagePaths(store), "DCIM/100ABCDE/ABCD0001.JPG")
assertPaths(t, "NonConforming", store.NonConforming,
"DCIM/100ABCDE/ABCD0002.JPG",
"DCIM/100ABCDE/LINK",
"DCIM/101ABCDE",
"DCIM/LINK",
"DCIM/MISC",
"DCIM/MISC/LINK",
"DCIM/MISC/README.TXT",
)
}
func TestParseStoreDoesNotFollowSymlinksUnderDCIM(t *testing.T) {
t.Parallel()
outside := newCardOnDisk(t, "OUTSIDE.JPG", "DIRECTORY/ABCD0009.JPG")
outsideFile := filepath.Join(outside, "OUTSIDE.JPG")
outsideDirectory := filepath.Join(outside, "DIRECTORY")
root := newCardOnDisk(t,
"DCIM/100ABCDE/ABCD0001.JPG",
"DCIM/102ABCDE/ABCD0002.JPG",
"DCIM/MISC/README.TXT",
)
// These two have DCF file names. The second has the number of a real JPG,
// which must not make that JPG shared.
addSymlink(t, root, "DCIM/100ABCDE/ABCD0003.JPG", outsideFile)
addSymlink(t, root, "DCIM/100ABCDE/PQRS0001.JPG", outsideFile)
// These two have DCF directory names. The second has the number of a real
// directory, which must not make that directory shared.
addSymlink(t, root, "DCIM/101ABCDE", outsideDirectory)
addSymlink(t, root, "DCIM/102PQRST", outsideDirectory)
addSymlink(t, root, "DCIM/LINK", outsideDirectory)
addSymlink(t, root, "DCIM/MISC/LINK", outsideDirectory)
store := mustParseStore(t, os.DirFS(root))
if got, want := directoryNumbers(store), []int{100, 102}; !slices.Equal(got, want) {
t.Errorf("directory numbers = %v, want %v", got, want)
}
assertPaths(t, "Images", imagePaths(store),
"DCIM/100ABCDE/ABCD0001.JPG",
"DCIM/102ABCDE/ABCD0002.JPG",
)
if got := store.TotalImageSize(); got != 6 {
t.Errorf("TotalImageSize() = %d, want 6", got)
}
assertPaths(t, "NonConforming", store.NonConforming,
"DCIM/100ABCDE/ABCD0003.JPG",
"DCIM/100ABCDE/PQRS0001.JPG",
"DCIM/101ABCDE",
"DCIM/102PQRST",
"DCIM/LINK",
"DCIM/MISC",
"DCIM/MISC/LINK",
"DCIM/MISC/README.TXT",
)
}
func TestParseStoreSkipsSymlinksAndNamesThatFSRefusesUnderM4Root(t *testing.T) {
t.Parallel()
outside := newCardOnDisk(t, "OUTSIDE.MP4")
// The names with the byte 0xff sort before CLIP, so a walk that stops at one
// of them does not reach it.
root := newCardOnDisk(t,
"PRIVATE/M4ROOT/A\xffDIR/C0002.MP4",
"PRIVATE/M4ROOT/A\xffFILE.MP4",
"PRIVATE/M4ROOT/CLIP/C0001.MP4",
)
addSymlink(t, root, "PRIVATE/M4ROOT/CLIP/C0003.MP4",
filepath.Join(outside, "OUTSIDE.MP4"))
addSymlink(t, root, "PRIVATE/M4ROOT/CLIP/C0004.MP4", "absent")
addSymlink(t, root, "PRIVATE/M4ROOT/LINK", outside)
store := mustParseStore(t, os.DirFS(root))
assertPaths(t, "Videos", videoPaths(store), "PRIVATE/M4ROOT/CLIP/C0001.MP4")
if got := store.TotalVideoSize(); got != 3 {
t.Errorf("TotalVideoSize() = %d, want 3", got)
}
}
func TestParseStoreTreatsSymlinksAsMissingDirectories(t *testing.T) {
t.Parallel()
outside := newCardOnDisk(t,
"100ABCDE/ABCD0001.JPG",
"M4ROOT/C0001.MP4",
"C0002.MP4",
)
cases := []struct{ link, target string }{
{dcimDirectory, outside},
{dcimDirectory, dcimDirectory},
{privateDirectory, outside},
{privateDirectory, privateDirectory},
{m4RootDirectory, outside},
}
for _, testCase := range cases {
root := newMountPoint(t, filepath.Dir(testCase.link))
addSymlink(t, root, testCase.link, testCase.target)
store := mustParseStore(t, os.DirFS(root))
if !reflect.DeepEqual(store, &Store{RootDirectory: testRoot}) {
t.Errorf("symlink at %s to %s read as %+v, want an empty store",
testCase.link, testCase.target, store)
}
}
}
// failingFS is a card on which one call fails for one path, the way a failing
// card does. The call is Lstat, ReadDir or Info.
type failingFS struct {
fstest.MapFS
call string
name string
}
func (f failingFS) Lstat(name string) (fs.FileInfo, error) {
err := f.failure("Lstat", name)
if err != nil {
return nil, err
}
return f.MapFS.Lstat(name)
}
func (f failingFS) ReadDir(name string) ([]fs.DirEntry, error) {
err := f.failure("ReadDir", name)
if err != nil {
return nil, err
}
entries, err := f.MapFS.ReadDir(name)
if err != nil {
return nil, err
}
for i, entry := range entries {
err = f.failure("Info", path.Join(name, entry.Name()))
if err != nil {
entries[i] = failingEntry{DirEntry: entry, err: err}
}
}
return entries, nil
}
func (f failingFS) failure(call, name string) error {
if call == f.call && name == f.name {
return &fs.PathError{Op: call, Path: name, Err: fs.ErrPermission}
}
return nil
}
// failingEntry is a directory entry whose Info always returns err.
type failingEntry struct {
fs.DirEntry
err error
}
func (e failingEntry) Info() (fs.FileInfo, error) {
return nil, e.err
}
func TestParseStoreReturnsReadErrorsNamingTheCard(t *testing.T) {
t.Parallel()
card := newCard(
"DCIM/100ABCDE/ABCD0001.JPG",
"DCIM/100ABCDE/SUB/README.TXT",
"DCIM/MISC/SUB/README.TXT",
"PRIVATE/M4ROOT/CLIP/C0001.MP4",
)
cases := map[string][]string{
"Lstat": {dcimDirectory, privateDirectory, m4RootDirectory},
"ReadDir": {
dcimDirectory,
"DCIM/100ABCDE",
"DCIM/100ABCDE/SUB",
"DCIM/MISC",
"DCIM/MISC/SUB",
m4RootDirectory,
"PRIVATE/M4ROOT/CLIP",
},
"Info": {"DCIM/100ABCDE/ABCD0001.JPG", "PRIVATE/M4ROOT/CLIP/C0001.MP4"},
}
for call, names := range cases {
for _, name := range names {
fsys := failingFS{MapFS: card, call: call, name: name}
_, err := parseStore(t.Context(), fsys, testRoot)
if !errors.Is(err, fs.ErrPermission) { if !errors.Is(err, fs.ErrPermission) {
t.Errorf("%s of %s: error = %v, want %v", t.Fatalf("parseStore error = %v, want %v", err, fs.ErrPermission)
call, name, err, fs.ErrPermission)
continue
}
want := "reading the card at " + testRoot + ": " +
call + " " + name + ": permission denied"
if err.Error() != want {
t.Errorf("error = %q, want %q", err.Error(), want)
}
}
}
}
// cancelingFS is a card that cancels the read when the read reaches the named
// directory. It records every directory read in read.
type cancelingFS struct {
fstest.MapFS
name string
cancel context.CancelFunc
read *[]string
}
func (f cancelingFS) ReadDir(name string) ([]fs.DirEntry, error) {
*f.read = append(*f.read, name)
if name == f.name {
f.cancel()
}
return f.MapFS.ReadDir(name)
}
func TestParseStoreStopsBeforeTheNextDirectoryWhenCancelled(t *testing.T) {
t.Parallel()
card := newCard(
"DCIM/100ABCDE/SUB/README.TXT",
"DCIM/OTHER/SUB/README.TXT",
"PRIVATE/M4ROOT/CLIP/SUB/C0001.MP4",
)
// Each of these directories holds another directory, which must not be read.
names := []string{
dcimDirectory,
"DCIM/100ABCDE",
"DCIM/OTHER",
m4RootDirectory,
"PRIVATE/M4ROOT/CLIP",
}
for _, name := range names {
ctx, cancel := context.WithCancel(t.Context())
var read []string
fsys := cancelingFS{MapFS: card, name: name, cancel: cancel, read: &read}
_, err := parseStore(ctx, fsys, testRoot)
if !errors.Is(err, context.Canceled) {
t.Errorf("cancelled at %s: error = %v, want %v",
name, err, context.Canceled)
}
if read[len(read)-1] != name {
t.Errorf("cancelled at %s: read %q", name, read)
}
}
}
func TestParseStoreReadsNoDirectoryWhenAlreadyCancelled(t *testing.T) {
t.Parallel()
ctx, cancel := context.WithCancel(t.Context())
cancel()
cards := []fstest.MapFS{
newCard("DCIM/README.TXT"),
newCard("PRIVATE/M4ROOT/C0001.MP4"),
}
for _, card := range cards {
var read []string
// The context is already cancelled, so this needs no name to cancel at.
fsys := cancelingFS{MapFS: card, read: &read}
_, err := parseStore(ctx, fsys, testRoot)
if !errors.Is(err, context.Canceled) {
t.Errorf("error = %v, want %v", err, context.Canceled)
}
assertPaths(t, "directories read", read)
} }
} }