InternalsDaemon

SafeFileOps

SafeFileOps

Secure rope-backed text file read/write utilities for the Unbound daemon. SafeFileOps provides cached, revision-tracked file I/O with path traversal protection and atomic writes.

Architecture

┌─────────────────────────────────────────────────────────────────┐
│                           Daemon                                 │
│                                                                  │
│  IPC Handler ──► SafeFileOps                                         │
│                    │                                             │
│                    ├── read_full()    ──► ReadFullResult         │
│                    ├── read_slice()   ──► ReadSliceResult        │
│                    ├── write_full()   ──► WriteResult            │
│                    └── replace_range() ─► WriteResult            │
│                                                                  │
│                 ┌─────────────────────────┐                      │
│                 │      RopeCache (LRU)    │                      │
│                 │  128 MB default cap     │                      │
│                 │  key: path → Rope       │                      │
│                 │  validated by revision  │                      │
│                 └─────────────────────────┘                      │
│                              │                                   │
│                    ┌─────────┼─────────┐                         │
│                    ▼         ▼         ▼                         │
│              Path Security  Atomic   UTF-8                       │
│              (traversal     Write    Safety                      │
│               prevention)  (tmp+mv)                              │
└─────────────────────────────────────────────────────────────────┘

Usage

Reading Files

use safe_file_ops::SafeFileOps;
use std::path::Path;

let g = SafeFileOps::with_defaults();
let root = Path::new("/path/to/repo");

// Read entire file (up to 1 MB)
let result = g.read_full(root, "src/main.rs", 1_000_000)?;

println!("Lines: {}", result.total_lines);
println!("Truncated: {}", result.is_truncated);

if let Some(reason) = &result.read_only_reason {
    println!("Read-only: {}", reason);
}

// Save revision for later write validation
let revision = result.revision;

Reading Line Ranges

// Read lines 10-30 (0-indexed, exclusive end)
let slice = g.read_slice(root, "src/main.rs", 10, 20, 500_000)?;

println!("Lines {}-{} of {}", slice.start_line, slice.end_line_exclusive, slice.total_lines);
println!("More before: {}, More after: {}", slice.has_more_before, slice.has_more_after);

Writing Files

// Write with optimistic locking (revision must match)
let result = g.write_full(
    root,
    "src/main.rs",
    "fn main() {}\n",
    Some(&revision),  // expected revision from previous read
    false,            // force=false, require revision match
)?;

// Force write (skip revision check)
let result = g.write_full(root, "src/new_file.rs", "// new\n", None, true)?;

Replacing Line Ranges

// Replace lines 5-10 with new content
let result = g.replace_range(
    root,
    "src/main.rs",
    5,               // start_line (0-indexed)
    10,              // end_line_exclusive
    "// replaced\n",
    Some(&revision),
    false,
)?;

Revision Tracking

Every read returns a FileRevision that captures the file's identity at that moment:

pub struct FileRevision {
    pub token: String,         // Hash of path + size + mtime
    pub len_bytes: u64,        // File size in bytes
    pub modified_unix_ns: u128, // Modification time (nanoseconds)
}

Writes validate the expected revision against the current file state. If another process modified the file, you get a RevisionConflict error with the current revision - preventing silent overwrites.

Caching

SafeFileOps maintains an LRU cache of parsed Rope data structures:

  • Default capacity: 128 MB total byte budget
  • Cache key: Canonical file path
  • Validation: Cache entries are invalidated when the file's revision changes
  • Eviction: Least-recently-used entries evicted when budget exceeded
  • Write-through: Writes update the cache with the new content

The Rope data structure (via ropey) enables efficient line-based operations on large files without copying the entire string.

Path Security

All operations validate paths to prevent directory traversal:

  • Relative paths only (no absolute paths, no .. components)
  • Canonicalization ensures resolved path stays within root
  • Separate resolution for reads (file must exist) vs writes (parent must exist)
// These are rejected:
g.read_full(root, "../etc/passwd", max)?;       // PathTraversal
g.read_full(root, "/etc/passwd", max)?;          // InvalidRelativePath
g.read_full(root, "src/../../etc/passwd", max)?; // PathTraversal

Atomic Writes

Writes use a temp-file-then-rename strategy for crash safety:

  1. Write to .{filename}.unbound.tmp.{nanos} in the same directory
  2. fsync the file
  3. Restore original Unix permissions (if updating)
  4. Atomic rename to final path
  5. fsync the parent directory

No partial writes are ever visible to readers.

Configuration

OptionDefaultDescription
cache_max_bytes128 MBMaximum total bytes in the rope cache
editable_max_bytes4 MBFiles larger than this are marked read-only
// Custom limits
let g = SafeFileOps::new(
    64 * 1024 * 1024,  // 64 MB cache
    2 * 1024 * 1024,   // 2 MB editable limit
);

Error Types

ErrorCause
InvalidRootRoot path doesn't exist
InvalidRelativePathEmpty, absolute, or malformed path
PathTraversalPath escapes the root directory
NotAFileTarget is a directory
NotFoundFile doesn't exist
InvalidUtf8File contains non-UTF-8 bytes
MissingExpectedRevisionWrite without revision and force=false
RevisionConflictFile changed since last read
InvalidRangeLine range out of bounds
IoUnderlying filesystem error

Testing

cargo test -p safe-file-ops

16 tests covering path security, read/write operations, UTF-8 handling, cache invalidation, LRU eviction, revision conflicts, atomic writes, and Unix permission preservation.