Skip to content

Commit fb950ba

Browse files
authored
Recover cmd (#21)
* Log loaded plugins * README extensions * Implement recover command
1 parent b677bf4 commit fb950ba

8 files changed

Lines changed: 197 additions & 98 deletions

File tree

README.md

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@
1717

1818
While many data recovery tools exist, few offer a combination of simplicity, flexibility, and modern design focused on deep disk analysis and effective file carving.
1919

20-
Digler was created to fill this gap by providing a streamlined, reliable command-line tool that makes data recovery easier and more efficientwithout the complexity of heavyweight GUIs or fragmented workflows.
20+
Digler was created to fill this gap by providing a streamlined, plugin-extensible command-line tool that makes data recovery easier and more efficient without the complexity of heavyweight GUIs or fragmented workflows.
2121

2222
Built in Go, Digler leverages the language’s strengths in performance, cross-platform support, and maintainability to deliver a fast and dependable solution for today’s data recovery challenges.
2323

@@ -28,9 +28,11 @@ Built in Go, Digler leverages the language’s strengths in performance, cross-p
2828

2929
* **File System Agnostic Analysis**: Recover deleted files regardless of the underlying file system (e.g., NTFS, FAT32, ext4), even when metadata is lost.
3030

31+
* **Plugin-Based Extensibility**: Support for custom file scanners through plugins, simplifying integration with new file formats.
32+
3133
* **Reporting Capabilities**: Generate detailed reports, compliant with the `Digital Forensics XML (DFXML)` format, of recovered data and analysis findings.
3234

33-
* **Post-Scan Data Recovery**: Utilize the generated DFXML reports to precisely recover deleted or fragmented files.
35+
* **Post-Scan Data Recovery**: Utilize the generated DFXML reports to precisely recover specific files.
3436

3537
* **Intuitive Command-Line Interface**: A user-friendly CLI designed for efficiency and ease of use.
3638

cmd/cmd/recover.go

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
// Copyright (c) 2025 Stefano Scafiti
2+
//
3+
// Permission is hereby granted, free of charge, to any person obtaining a copy
4+
// of this software and associated documentation files (the "Software"), to deal
5+
// in the Software without restriction, including without limitation the rights
6+
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
7+
// copies of the Software, and to permit persons to whom the Software is
8+
// furnished to do so, subject to the following conditions:
9+
//
10+
// The above copyright notice and this permission notice shall be included in
11+
// all copies or substantial portions of the Software.
12+
//
13+
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
14+
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
15+
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
16+
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
17+
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
18+
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
19+
// THE SOFTWARE.
20+
package cmd
21+
22+
import (
23+
"bufio"
24+
"os"
25+
"path/filepath"
26+
"strings"
27+
28+
"github.com/ostafen/digler/internal/fs"
29+
"github.com/ostafen/digler/internal/logger"
30+
"github.com/ostafen/digler/internal/scan"
31+
"github.com/ostafen/digler/pkg/dfxml"
32+
osutils "github.com/ostafen/digler/pkg/util/os"
33+
"github.com/spf13/cobra"
34+
)
35+
36+
func DefineRecoverCommand() *cobra.Command {
37+
cmd := &cobra.Command{
38+
Use: "recover <image_path> <report_file>",
39+
Short: "Recover files from a disk image using a scan report",
40+
Long: `The 'recover' command extracts files from a disk image or device based on the information provided in a scan report.
41+
The scan report contains metadata and file information needed for recovery.
42+
You must provide the full path to the image file and the report file.
43+
Recovered files will be saved to the specified output directory.`,
44+
Args: cobra.ExactArgs(2),
45+
SilenceUsage: true,
46+
RunE: RunRecover,
47+
}
48+
cmd.Flags().StringP("output-dir", "i", "", "Absolute path to the directory where recovered data will be placed.")
49+
return cmd
50+
}
51+
52+
func RunRecover(cmd *cobra.Command, args []string) error {
53+
f, err := fs.Open(args[0])
54+
if err != nil {
55+
return err
56+
}
57+
defer f.Close()
58+
59+
reportFile, err := os.Open(args[1])
60+
if err != nil {
61+
return err
62+
}
63+
64+
objects, err := dfxml.ReadFileObjects(bufio.NewReader(reportFile))
65+
if err != nil {
66+
return err
67+
}
68+
69+
outDir, _ := cmd.Flags().GetString("output-dir")
70+
if outDir == "" {
71+
wdir, err := os.Getwd()
72+
if err != nil {
73+
return err
74+
}
75+
76+
base := filepath.Base(reportFile.Name())
77+
name := strings.TrimSuffix(base, filepath.Ext(base))
78+
outDir = filepath.Join(wdir, name+"-dump")
79+
}
80+
81+
_, err = osutils.EnsureDir(outDir, true)
82+
if err != nil {
83+
return err
84+
}
85+
86+
finfos, err := fileObjectsToFileInfo(objects)
87+
if err != nil {
88+
return err
89+
}
90+
91+
logger := logger.New(os.Stdout, logger.InfoLevel)
92+
93+
for _, finfo := range finfos {
94+
logger.Infof("recovering file %s", filepath.Join(outDir, finfo.Name))
95+
96+
if err := scan.DumpFile(f, outDir, &finfo); err != nil {
97+
logger.Errorf("unable to dump file %s: %s", finfo.Name, err)
98+
}
99+
}
100+
return nil
101+
}

cmd/cmd/root.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ func Execute() error {
3131
}
3232

3333
rootCmd.AddCommand(DefineScanCommand())
34+
rootCmd.AddCommand(DefineRecoverCommand())
3435
rootCmd.AddCommand(DefineMountCommand())
3536
rootCmd.AddCommand(DefineFormatsCommand())
3637

internal/disk/mbr.go

Lines changed: 3 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,8 @@ package disk
2222
import (
2323
"encoding/binary"
2424
"fmt"
25+
26+
fmtutils "github.com/ostafen/digler/pkg/util/format"
2527
)
2628

2729
// MBRPartitionEntry represents a single 16-byte entry in the MBR's partition table.
@@ -62,7 +64,7 @@ func (p *MBRPartitionEntry) String() string {
6264
p.ReadStartLBA(),
6365
p.ReadTotalSectors(),
6466
p.ReadTotalSectors()*512, // Assuming 512 bytes per sector
65-
formatBytes(uint64(p.ReadTotalSectors())*512))
67+
fmtutils.FormatBytes(int64(p.ReadTotalSectors())*512))
6668
}
6769

6870
// MBR represents the Master Boot Record structure.
@@ -202,25 +204,3 @@ func getPartitionTypeName(id MBRPartition) string {
202204
return "Unknown"
203205
}
204206
}
205-
206-
func formatBytes(b uint64) string {
207-
const (
208-
_ = iota // 0
209-
KB = 1 << (10 * iota) // 1 << 10 (1024)
210-
MB = 1 << (10 * iota) // 1 << 20 (1024 * 1024)
211-
GB = 1 << (10 * iota) // 1 << 30 (1024 * 1024 * 1024)
212-
TB = 1 << (10 * iota) // 1 << 40 (1024 * 1024 * 1024 * 1024)
213-
)
214-
switch {
215-
case b >= TB:
216-
return fmt.Sprintf("%.2f TB", float64(b)/float64(TB))
217-
case b >= GB:
218-
return fmt.Sprintf("%.2f GB", float64(b)/float64(GB))
219-
case b >= MB:
220-
return fmt.Sprintf("%.2f MB", float64(b)/float64(MB))
221-
case b >= KB:
222-
return fmt.Sprintf("%.2f KB", float64(b)/float64(KB))
223-
default: // Handle bytes less than 1KB
224-
return fmt.Sprintf("%d B", b)
225-
}
226-
}

internal/format/header.go

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,6 @@ package format
2222
import (
2323
"fmt"
2424
"plugin"
25-
"reflect"
2625
)
2726

2827
type ScanResult struct {
@@ -117,7 +116,6 @@ func loadPlugin(path string) (FileScanner, error) {
117116
return nil, fmt.Errorf("plugin %s does not export FileScanner symbol: %w", path, err)
118117
}
119118

120-
fmt.Println(reflect.TypeOf(symScanner))
121119
getScanner, ok := symScanner.(func() (FileScanner, error))
122120
if !ok {
123121
return nil, fmt.Errorf("plugin %s GetScanner has wrong type", path)

internal/fuse/mount_linux.go

Lines changed: 2 additions & 57 deletions
Original file line numberDiff line numberDiff line change
@@ -23,8 +23,6 @@
2323
package fuse
2424

2525
import (
26-
"errors"
27-
"fmt"
2826
"io"
2927
"log"
3028
"os"
@@ -34,10 +32,11 @@ import (
3432
"bazil.org/fuse"
3533
fusefs "bazil.org/fuse/fs"
3634
"github.com/ostafen/digler/internal/format"
35+
osutils "github.com/ostafen/digler/pkg/util/os"
3736
)
3837

3938
func Mount(mountpoint string, r io.ReaderAt, finfos []format.FileInfo) error {
40-
created, err := PrepareMountpoint(mountpoint)
39+
created, err := osutils.EnsureDir(mountpoint, true)
4140
if err != nil {
4241
return err
4342
}
@@ -104,57 +103,3 @@ func waitForUmount(mountpoint string) error {
104103
}
105104
return nil
106105
}
107-
108-
// PrepareMountpoint ensures the given path is a valid, empty directory suitable for FUSE mounting.
109-
// It creates the directory if it doesn't exist. Returns `true` if created, `false` otherwise,
110-
// or an error if the path exists but isn't an empty directory.
111-
func PrepareMountpoint(mountpoint string) (bool, error) {
112-
finfo, err := os.Stat(mountpoint)
113-
if errors.Is(err, os.ErrNotExist) {
114-
err := os.Mkdir(mountpoint, 0755)
115-
if err != nil {
116-
return false, fmt.Errorf("failed to create mountpoint %s: %w", mountpoint, err)
117-
}
118-
return true, nil
119-
}
120-
if err != nil {
121-
return false, fmt.Errorf("failed to stat mountpoint %s: %w", mountpoint, err)
122-
}
123-
124-
if !finfo.IsDir() {
125-
return false, fmt.Errorf("mountpoint %s is not a directory", mountpoint)
126-
}
127-
128-
empty, err := IsDirEmpty(mountpoint)
129-
if err != nil {
130-
return false, fmt.Errorf("failed to check if mountpoint %s is empty: %w", mountpoint, err)
131-
}
132-
133-
if !empty {
134-
return false, fmt.Errorf("mountpoint %s is not empty", mountpoint)
135-
}
136-
return false, nil
137-
}
138-
139-
// IsDirEmpty returns true if the directory at path is empty, false otherwise.
140-
// Returns an error if the path does not exist or is not a directory.
141-
func IsDirEmpty(path string) (bool, error) {
142-
f, err := os.Open(path)
143-
if err != nil {
144-
return false, err
145-
}
146-
defer f.Close()
147-
148-
entries, err := f.Readdir(1)
149-
if err != nil {
150-
if err == io.EOF {
151-
return true, nil
152-
}
153-
return false, err
154-
}
155-
156-
if len(entries) > 0 {
157-
return false, nil
158-
}
159-
return true, nil
160-
}

internal/scan/scan.go

Lines changed: 21 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -90,11 +90,11 @@ func ScanPartition(p *disk.Partition, filePath string, opts Options) error {
9090
return err
9191
}
9292

93-
session := GenSessionID()
93+
scanID := GetScanID()
9494

9595
var reportFileName string
9696
if opts.ReportFile == "" {
97-
reportFileName = fmt.Sprintf("report_%s.xml", session)
97+
reportFileName = fmt.Sprintf("report_%s.xml", scanID)
9898
}
9999

100100
outFile, err := os.Create(reportFileName)
@@ -131,16 +131,17 @@ func ScanPartition(p *disk.Partition, filePath string, opts Options) error {
131131

132132
var logFilePath string
133133
if !opts.DisableLog {
134-
logFilePath = absPath(filepath.Join(opts.DumpDir, session) + ".log")
134+
logFilePath = absPath(filepath.Join(opts.DumpDir, scanID) + ".log")
135135
}
136136

137137
scanners, err := format.GetFileScanners(opts.FileExt...)
138138
if err != nil {
139139
return err
140140
}
141141

142+
var pluginScanners []format.FileScanner
142143
if len(opts.Plugins) > 0 {
143-
pluginScanners, err := format.LoadPlugins(opts.Plugins...)
144+
pluginScanners, err = format.LoadPlugins(opts.Plugins...)
144145
if err != nil {
145146
return err
146147
}
@@ -166,6 +167,12 @@ func ScanPartition(p *disk.Partition, filePath string, opts Options) error {
166167
logger.Infof("Source: \t%s", absPath(filePath))
167168
logger.Infof("File Types: \t%s", strings.Join(fileExts, ","))
168169

170+
if len(pluginScanners) > 0 {
171+
logger.Infof("Loaded %d plugins(s): \t%s", len(pluginScanners), strings.Join(opts.Plugins, ","))
172+
} else {
173+
logger.Infof("No plugin loaded")
174+
}
175+
169176
if opts.DumpDir != "" {
170177
logger.Infof("Destination: \t%s", absPath(opts.DumpDir))
171178
}
@@ -202,11 +209,8 @@ func ScanPartition(p *disk.Partition, filePath string, opts Options) error {
202209
totalDataSize += finfo.Size
203210

204211
if opts.DumpDir != "" {
205-
fileReader := io.NewSectionReader(r, int64(finfo.Offset), int64(finfo.Size))
206-
207-
err := ioutil.CopyFile(filepath.Join(opts.DumpDir, finfo.Name), fileReader)
208-
if err != nil {
209-
return err
212+
if err := DumpFile(r, opts.DumpDir, &finfo); err != nil {
213+
logger.Errorf("unable to dump file %s: %s", finfo.Name, err)
210214
}
211215
}
212216

@@ -238,6 +242,12 @@ func ScanPartition(p *disk.Partition, filePath string, opts Options) error {
238242
return nil
239243
}
240244

245+
func DumpFile(r io.ReaderAt, outDir string, finfo *format.FileInfo) error {
246+
fileReader := io.NewSectionReader(r, int64(finfo.Offset), int64(finfo.Size))
247+
248+
return ioutil.CopyFile(filepath.Join(outDir, finfo.Name), fileReader)
249+
}
250+
241251
func DiscoverPartitions(path string) ([]disk.Partition, error) {
242252
imgFile, err := fs.Open(path)
243253
if err != nil {
@@ -263,9 +273,6 @@ func DiscoverPartitions(path string) ([]disk.Partition, error) {
263273
}
264274
}
265275

266-
// TODO: if was unable to determine the partitions,
267-
// Maybe, try to locate partition boundaries using FAT signature, etc?
268-
269276
finfo, err := imgFile.Stat()
270277
if err != nil {
271278
return nil, err
@@ -337,9 +344,9 @@ func GetMBRPartitions(imgFile fs.File, mbr *disk.MBR) ([]disk.Partition, error)
337344
return partitions, nil
338345
}
339346

340-
// GenSessionID creates a unique file name for a scan session.
347+
// GetScanID creates a unique file name for a scan session.
341348
// The format is "scan_YYYYMMDD_HHMMSS".
342-
func GenSessionID() string {
349+
func GetScanID() string {
343350
now := time.Now()
344351

345352
// Format the time as YYYYMMDD_HHMMSS

0 commit comments

Comments
 (0)