Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 18 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,16 +33,22 @@ sietch init --name dune --key-type aes # AES-256-GCM encryption
sietch init --name dune --key-type chacha20 # ChaCha20-Poly1305 encryption
```

**Add files**
**Add files, directories, and symlinks**
```bash
# Single file
sietch add ./secrets/thumper-plans.pdf documents/

# Entire directory (recursive)
sietch add ~/photos vault/photos/

# Multiple files with individual destinations
sietch add file1.txt dest1/ file2.txt dest2/

# Multiple files to single destination
sietch add ~/photos/img1.jpg ~/photos/img2.jpg vault/photos/
# Multiple sources to single destination
sietch add ~/photos/img1.jpg ~/docs/ vault/backup/

# Symlinks (follows and adds target content)
sietch add ~/link-to-file.txt vault/files/
```

**Sync over LAN**
Expand All @@ -63,6 +69,8 @@ sietch get thumper-plans.pdf ./retrieved/
| -------------------- | --------------------------------------------------------------------- |
| **AES256/GPG** | Files are chunked and encrypted with strong symmetric/asymmetric keys |
| **ChaCha20** | Modern authenticated encryption with ChaCha20-Poly1305 AEAD |
| **Directory Support**| Recursively add entire directories while preserving structure |
| **Symlink Handling** | Automatically follows symlinks and stores target content |
| **Offline Sync** | Rsync-style syncing over TCP or LibP2P |
| **Gossip Discovery** | Lightweight peer discovery protocol for LAN environments |
| **CLI First UX** | Fast, minimal CLI to manage vaults and syncs |
Expand All @@ -73,6 +81,13 @@ sietch get thumper-plans.pdf ./retrieved/
* Files are split into configurable chunks (default: 4MB)
* Identical chunks across files are deduplicated to save space

### Directory & Symlink Handling
* **Directories**: Recursively processes all files while preserving directory structure
* **Symlinks to files**: Follows the symlink and stores the target file content
* **Symlinks to directories**: Recursively processes all files in the target directory
* **Hidden files**: Included automatically (files starting with `.`)
* **Nested structures**: Full directory hierarchy is maintained in the vault

### Encryption
Each chunk is encrypted before storage using:
* **Symmetric**: AES-256-GCM or ChaCha20-Poly1305 with passphrase
Expand Down
217 changes: 146 additions & 71 deletions cmd/add.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ package cmd
import (
"context"
"fmt"
"os"
"path/filepath"
"strings"
"time"
Expand Down Expand Up @@ -33,23 +34,36 @@ type SpaceSavings struct {
// addCmd represents the add command
var addCmd = &cobra.Command{
Use: "add <source_path> <destination_path> [source_path2] [destination_path2]...",
Short: "Add one or more files to the Sietch vault",
Long: `Add multiple files to your Sietch vault.
Short: "Add files, directories, or symlinks to the Sietch vault",
Long: `Add files, directories, or symlinks to your Sietch vault.

This command adds files from the specified source paths to the destination
paths in your vault, then processes them according to your vault configuration.
paths in your vault. It supports regular files, directories (recursively),
and symbolic links (by copying their contents).

Supports two usage patterns:
1. Paired arguments: sietch add source1 dest1 source2 dest2 ...
Each source file is stored at its corresponding destination path.
Each source is stored at its corresponding destination path.

2. Single destination: sietch add source1 source2 ... dest
All source files are stored under the same destination directory.
All sources are stored under the same destination directory.

Directory handling:
- Directories are processed recursively
- Directory structure is preserved in the destination
- Hidden files and directories are included
- Symlinks within directories are followed

Symlink handling:
- Symlinks to files: the target file content is added
- Symlinks to directories: all files in the target directory are added recursively

Examples:
sietch add document.txt vault/documents/
sietch add file1.txt dest1/ file2.txt dest2/
sietch add ~/photos/img1.jpg ~/photos/img2.jpg vault/photos/`,
sietch add ~/photos vault/photos/
sietch add ~/link-to-file.txt vault/files/
sietch add ~/photos/img1.jpg ~/docs/ vault/backup/`,
Args: cobra.MinimumNArgs(2),
RunE: func(cmd *cobra.Command, args []string) error {
// Validate argument count (reasonable limit for batch operations)
Expand Down Expand Up @@ -138,88 +152,151 @@ Examples:
fmt.Printf("Processing: %s\n", pair.Source)
}

// Check if file exists and that it is not a directory or symlink
fileInfo, err := fs.VerifyFileAndReturnFileInfo(pair.Source)
// Check if path exists (accepts files, directories, and symlinks)
pathInfo, err := fs.VerifyPathAndReturnInfo(pair.Source)
if err != nil {
errorMsg := fmt.Sprintf("✗ %s: %v", filepath.Base(pair.Source), err)
fmt.Println(errorMsg)
failedFiles = append(failedFiles, errorMsg)
continue
}

// Get file size in human-readable format
sizeInBytes := fileInfo.Size()
sizeReadable := util.HumanReadableSize(sizeInBytes)

// Display file metadata for confirmation (only for single files or when verbose)
verbose, _ := cmd.Flags().GetBool("verbose")
if len(filePairs) == 1 || verbose {
fmt.Printf(" Size: %s (%d bytes)\n", sizeReadable, sizeInBytes)
fmt.Printf(" Modified: %s\n", fileInfo.ModTime().Format(time.RFC3339))
if len(tags) > 0 {
fmt.Printf(" Tags: %s\n", strings.Join(tags, ", "))
// Collect all files to process (handles directories and symlinks)
var filesToProcess []string
if pathInfo.IsDir() || pathInfo.Mode()&os.ModeSymlink != 0 {
// For directories and symlinks, recursively collect all files
filesToProcess, err = fs.CollectFilesRecursively(pair.Source)
if err != nil {
errorMsg := fmt.Sprintf("✗ %s: %v", filepath.Base(pair.Source), err)
fmt.Println(errorMsg)
failedFiles = append(failedFiles, errorMsg)
continue
}
}

// Process the file and store chunks - using the appropriate chunking function
var chunkRefs []config.ChunkRef
chunkRefs, err = chunk.ChunkFile(ctx, pair.Source, chunkSize, vaultRoot, passphrase, progressMgr)
if len(filesToProcess) == 0 {
fmt.Printf("⚠ %s: directory is empty, skipping\n", filepath.Base(pair.Source))
continue
}

if err != nil {
errorMsg := fmt.Sprintf("✗ %s: chunking failed - %v", filepath.Base(pair.Source), err)
fmt.Println(errorMsg)
failedFiles = append(failedFiles, errorMsg)
continue
fmt.Printf(" Found %d file(s) to add\n", len(filesToProcess))
} else {
// Regular file
filesToProcess = []string{pair.Source}
}

// Create and store the file manifest
fileManifest := &config.FileManifest{
FilePath: filepath.Base(pair.Source),
Size: sizeInBytes,
ModTime: fileInfo.ModTime().Format(time.RFC3339),
Chunks: chunkRefs,
Destination: pair.Destination,
AddedAt: time.Now().UTC(),
Tags: tags, // Include tags in the manifest
}
// Process each collected file
processedCount := 0
for _, sourceFile := range filesToProcess {
// Get file info for the actual file
fileInfo, err := fs.VerifyFileAndReturnFileInfo(sourceFile)
if err != nil {
errorMsg := fmt.Sprintf("✗ %s: %v", filepath.Base(sourceFile), err)
fmt.Println(errorMsg)
failedFiles = append(failedFiles, errorMsg)
continue
}

// Save the manifest
err = manifest.StoreFileManifest(vaultRoot, filepath.Base(pair.Source), fileManifest)
if err != nil {
errorMsg := fmt.Sprintf("✗ %s: manifest storage failed - %v", filepath.Base(pair.Source), err)
fmt.Println(errorMsg)
failedFiles = append(failedFiles, errorMsg)
continue
}
// Get file size in human-readable format
sizeInBytes := fileInfo.Size()
sizeReadable := util.HumanReadableSize(sizeInBytes)

// Calculate space savings for this file
spaceSavings := calculateSpaceSavings(chunkRefs)
// Display file metadata for confirmation (only for single files or when verbose)
if len(filePairs) == 1 || verbose {
fmt.Printf(" %s: %s (%d bytes)\n", filepath.Base(sourceFile), sizeReadable, sizeInBytes)
if verbose {
fmt.Printf(" Modified: %s\n", fileInfo.ModTime().Format(time.RFC3339))
}
}

// Success message
if len(filePairs) > 1 {
fmt.Printf("✓ %s (%d chunks", filepath.Base(pair.Source), len(chunkRefs))
if spaceSavings.SpaceSaved > 0 {
fmt.Printf(", %s saved", util.HumanReadableSize(spaceSavings.SpaceSaved))
// Calculate relative path for destination if processing directory
var destPath string
if len(filesToProcess) > 1 {
// For directories, preserve the structure
relPath, err := filepath.Rel(pair.Source, sourceFile)
if err != nil {
// If we can't get relative path, use just the destination directory
destPath = pair.Destination
} else {
// Destination should be directory only (excluding the filename)
// The FilePath field in manifest will have the filename
destPath = filepath.Join(pair.Destination, filepath.Dir(relPath))
// Ensure it ends with / for directory paths
if destPath != "" && !strings.HasSuffix(destPath, "/") {
destPath += "/"
}
}
} else {
// For single files, destination is the directory
destPath = pair.Destination
}
fmt.Printf(")\n")
} else {
fmt.Printf("✓ File added to vault: %s\n", filepath.Base(pair.Source))
fmt.Printf("✓ %d chunks stored in vault\n", len(chunkRefs))
if spaceSavings.SpaceSaved > 0 {
fmt.Printf("✓ Space saved: %s (%.1f%%)\n",
util.HumanReadableSize(spaceSavings.SpaceSaved),
spaceSavings.SpaceSavedPct)

// Process the file and store chunks
var chunkRefs []config.ChunkRef
chunkRefs, err = chunk.ChunkFile(ctx, sourceFile, chunkSize, vaultRoot, passphrase, progressMgr)

if err != nil {
errorMsg := fmt.Sprintf("✗ %s: chunking failed - %v", filepath.Base(sourceFile), err)
fmt.Println(errorMsg)
failedFiles = append(failedFiles, errorMsg)
continue
}
fmt.Printf("✓ Manifest written to .sietch/manifests/%s.yaml\n", filepath.Base(pair.Source))
}

successCount++
// Create and store the file manifest
fileManifest := &config.FileManifest{
FilePath: filepath.Base(sourceFile),
Size: sizeInBytes,
ModTime: fileInfo.ModTime().Format(time.RFC3339),
Chunks: chunkRefs,
Destination: destPath,
AddedAt: time.Now().UTC(),
Tags: tags, // Include tags in the manifest
}

// Add to total space savings
fileSavings := calculateSpaceSavings(chunkRefs)
totalSpaceSavings.OriginalSize += fileSavings.OriginalSize
totalSpaceSavings.CompressedSize += fileSavings.CompressedSize
totalSpaceSavings.SpaceSaved += fileSavings.SpaceSaved
// Save the manifest
err = manifest.StoreFileManifest(vaultRoot, filepath.Base(sourceFile), fileManifest)
if err != nil {
errorMsg := fmt.Sprintf("✗ %s: manifest storage failed - %v", filepath.Base(sourceFile), err)
fmt.Println(errorMsg)
failedFiles = append(failedFiles, errorMsg)
continue
}

// Calculate space savings for this file
fileSavings := calculateSpaceSavings(chunkRefs)
totalSpaceSavings.OriginalSize += fileSavings.OriginalSize
totalSpaceSavings.CompressedSize += fileSavings.CompressedSize
totalSpaceSavings.SpaceSaved += fileSavings.SpaceSaved

// Success message (compact for directories)
if len(filesToProcess) > 1 {
fmt.Printf(" ✓ %s (%d chunks)\n", filepath.Base(sourceFile), len(chunkRefs))
} else if len(filePairs) > 1 {
fmt.Printf("✓ %s (%d chunks", filepath.Base(sourceFile), len(chunkRefs))
if fileSavings.SpaceSaved > 0 {
fmt.Printf(", %s saved", util.HumanReadableSize(fileSavings.SpaceSaved))
}
fmt.Printf(")\n")
} else {
fmt.Printf("✓ File added to vault: %s\n", filepath.Base(sourceFile))
fmt.Printf("✓ %d chunks stored in vault\n", len(chunkRefs))
if fileSavings.SpaceSaved > 0 {
fmt.Printf("✓ Space saved: %s (%.1f%%)\n",
util.HumanReadableSize(fileSavings.SpaceSaved),
fileSavings.SpaceSavedPct)
}
fmt.Printf("✓ Manifest written to .sietch/manifests/%s.yaml\n", filepath.Base(sourceFile))
}

processedCount++
}

// Update success count for this pair
if processedCount > 0 {
successCount += processedCount
if len(filesToProcess) > 1 {
fmt.Printf("✓ Added %d file(s) from %s\n", processedCount, filepath.Base(pair.Source))
}
}
}

// Cleanup progress manager
Expand Down Expand Up @@ -366,6 +443,4 @@ func init() {
addCmd.Flags().StringP("passphrase-value", "p", "", "Passphrase for encrypted vault (if required)")
}

//TODO: Add support for directories and symlinks
//TODO: Need to check how symlinks will be handled
//TODO: Interactive mode with real time progress indicators
12 changes: 7 additions & 5 deletions cmd/add_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -101,15 +101,16 @@ func TestAddCommandUsageText(t *testing.T) {
}

func TestAddCommandLongDescription(t *testing.T) {
// Check that long description contains multiple file support information
// Check that long description contains file, directory, and symlink support information
longText := addCmd.Long

expectedPhrases := []string{
"multiple files",
"Paired arguments",
"Single destination",
"source1 dest1 source2 dest2",
"source1 source2 ... dest",
"directories",
"symlinks",
}

for _, phrase := range expectedPhrases {
Expand All @@ -120,11 +121,12 @@ func TestAddCommandLongDescription(t *testing.T) {
}

func TestAddCommandShortDescription(t *testing.T) {
// Check that short description reflects multiple file support
// Check that short description reflects file, directory, and symlink support
shortText := addCmd.Short

if !strings.Contains(shortText, "one or more files") {
t.Errorf("Short description should indicate multiple file support, got: %s", shortText)
// Should mention either "files" or "directories" or "symlinks"
if !strings.Contains(shortText, "files") && !strings.Contains(shortText, "directories") && !strings.Contains(shortText, "symlinks") {
t.Errorf("Short description should indicate file, directory, or symlink support, got: %s", shortText)
}
}

Expand Down
Loading
Loading