jax-daemon 0.1.17

End-to-end encrypted storage buckets with peer-to-peer synchronization
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
//! LRU cache with TTL for FUSE file contents
//!
//! This cache stores file content and metadata to reduce HTTP API calls.
//! It supports time-based expiration and size-based eviction.

use std::sync::Arc;
use std::time::Duration;

use moka::sync::Cache;
use serde::{Deserialize, Serialize};

/// Cached file content
#[derive(Debug, Clone)]
pub struct CachedContent {
    /// File content bytes
    pub data: Arc<Vec<u8>>,
    /// MIME type
    pub mime_type: String,
}

/// Cached file/directory attributes
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CachedAttr {
    /// Size in bytes
    pub size: u64,
    /// Is this a directory?
    pub is_dir: bool,
    /// MIME type for files
    pub mime_type: Option<String>,
    /// Modification time (Unix timestamp)
    pub mtime: i64,
}

/// Cached directory listing
#[derive(Debug, Clone)]
pub struct CachedDirEntry {
    pub name: String,
    pub is_dir: bool,
}

/// Configuration for the file cache
#[derive(Debug, Clone)]
pub struct FileCacheConfig {
    /// Maximum cache size in megabytes
    pub max_size_mb: u32,
    /// TTL for metadata (attrs, dirs) in seconds
    pub ttl_secs: u32,
    /// TTL for content cache in seconds (defaults to 5x metadata TTL)
    pub content_ttl_secs: u32,
    /// TTL for negative cache (non-existent paths) in seconds
    pub negative_ttl_secs: u32,
}

impl Default for FileCacheConfig {
    fn default() -> Self {
        Self {
            max_size_mb: 100,
            ttl_secs: 60,
            content_ttl_secs: 300,
            negative_ttl_secs: 10,
        }
    }
}

impl FileCacheConfig {
    /// Create config from basic parameters, deriving content and negative TTLs
    pub fn from_basic(max_size_mb: u32, ttl_secs: u32) -> Self {
        Self {
            max_size_mb,
            ttl_secs,
            content_ttl_secs: ttl_secs.saturating_mul(5),
            negative_ttl_secs: 10,
        }
    }
}

/// LRU cache for FUSE filesystem
#[derive(Clone)]
pub struct FileCache {
    /// Content cache: path → content
    content: Cache<String, CachedContent>,
    /// Attribute cache: path → attributes
    attrs: Cache<String, CachedAttr>,
    /// Directory listing cache: path → entries
    dirs: Cache<String, Vec<CachedDirEntry>>,
    /// Negative cache: paths confirmed not to exist
    negative: Cache<String, ()>,
    /// Configuration
    config: FileCacheConfig,
}

impl FileCache {
    /// Create a new file cache with the given configuration
    pub fn new(config: FileCacheConfig) -> Self {
        let metadata_ttl = Duration::from_secs(config.ttl_secs as u64);
        let content_ttl = Duration::from_secs(config.content_ttl_secs as u64);
        let negative_ttl = Duration::from_secs(config.negative_ttl_secs as u64);
        // Estimate ~1KB average per entry for size calculation
        let max_capacity = (config.max_size_mb as u64) * 1024;

        Self {
            content: Cache::builder()
                .time_to_live(content_ttl)
                .max_capacity(max_capacity)
                .build(),
            attrs: Cache::builder()
                .time_to_live(metadata_ttl)
                .max_capacity(max_capacity * 10) // Attrs are smaller
                .build(),
            dirs: Cache::builder()
                .time_to_live(metadata_ttl)
                .max_capacity(max_capacity)
                .build(),
            negative: Cache::builder()
                .time_to_live(negative_ttl)
                .max_capacity(10_000) // Non-existent paths are tiny
                .build(),
            config,
        }
    }

    /// Get cached content for a path
    pub fn get_content(&self, path: &str) -> Option<CachedContent> {
        self.content.get(&Self::normalize_key(path))
    }

    /// Cache content for a path
    pub fn put_content(&self, path: &str, content: CachedContent) {
        self.content.insert(Self::normalize_key(path), content);
    }

    /// Get cached attributes for a path
    pub fn get_attr(&self, path: &str) -> Option<CachedAttr> {
        self.attrs.get(&Self::normalize_key(path))
    }

    /// Cache attributes for a path
    pub fn put_attr(&self, path: &str, attr: CachedAttr) {
        self.attrs.insert(Self::normalize_key(path), attr);
    }

    /// Get cached directory listing
    pub fn get_dir(&self, path: &str) -> Option<Vec<CachedDirEntry>> {
        self.dirs.get(&Self::normalize_key(path))
    }

    /// Cache directory listing
    pub fn put_dir(&self, path: &str, entries: Vec<CachedDirEntry>) {
        self.dirs.insert(Self::normalize_key(path), entries);
    }

    /// Check if a path is in the negative cache (known not to exist)
    pub fn is_negative(&self, path: &str) -> bool {
        self.negative.contains_key(&Self::normalize_key(path))
    }

    /// Add a path to the negative cache (mark as non-existent)
    pub fn put_negative(&self, path: &str) {
        self.negative.insert(Self::normalize_key(path), ());
    }

    /// Invalidate a specific path from all caches
    pub fn invalidate(&self, path: &str) {
        let key = Self::normalize_key(path);
        self.content.invalidate(&key);
        self.attrs.invalidate(&key);
        self.dirs.invalidate(&key);
        self.negative.invalidate(&key);
    }

    /// Invalidate all entries under a path prefix
    pub fn invalidate_prefix(&self, prefix: &str) {
        let prefix = Self::normalize_key(prefix);

        // Moka doesn't have prefix invalidation, so we need to iterate
        // For content cache
        self.content.run_pending_tasks();
        // Note: We can't efficiently iterate moka caches, so we just invalidate_all
        // in practice for prefix invalidations
        if prefix == "/" {
            self.invalidate_all();
        }
    }

    /// Invalidate all cached entries
    pub fn invalidate_all(&self) {
        self.content.invalidate_all();
        self.attrs.invalidate_all();
        self.dirs.invalidate_all();
        self.negative.invalidate_all();
    }

    /// Get current cache statistics
    pub fn stats(&self) -> CacheStats {
        CacheStats {
            content_count: self.content.entry_count(),
            attr_count: self.attrs.entry_count(),
            dir_count: self.dirs.entry_count(),
            negative_count: self.negative.entry_count(),
            max_size_mb: self.config.max_size_mb,
            metadata_ttl_secs: self.config.ttl_secs,
            content_ttl_secs: self.config.content_ttl_secs,
            negative_ttl_secs: self.config.negative_ttl_secs,
        }
    }

    /// Normalize a path to a consistent cache key
    fn normalize_key(path: &str) -> String {
        let path = path.trim();
        if path.is_empty() || path == "/" {
            return "/".to_string();
        }

        let mut key = if path.starts_with('/') {
            path.to_string()
        } else {
            format!("/{}", path)
        };

        if key.len() > 1 && key.ends_with('/') {
            key.pop();
        }

        key
    }
}

impl std::fmt::Debug for FileCache {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        f.debug_struct("FileCache")
            .field("config", &self.config)
            .field("content_count", &self.content.entry_count())
            .field("attr_count", &self.attrs.entry_count())
            .field("dir_count", &self.dirs.entry_count())
            .field("negative_count", &self.negative.entry_count())
            .finish()
    }
}

/// Cache statistics
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CacheStats {
    pub content_count: u64,
    pub attr_count: u64,
    pub dir_count: u64,
    pub negative_count: u64,
    pub max_size_mb: u32,
    pub metadata_ttl_secs: u32,
    pub content_ttl_secs: u32,
    pub negative_ttl_secs: u32,
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_content_cache() {
        let cache = FileCache::new(FileCacheConfig::default());

        let content = CachedContent {
            data: Arc::new(vec![1, 2, 3]),
            mime_type: "text/plain".to_string(),
        };

        cache.put_content("/foo.txt", content.clone());

        let cached = cache.get_content("/foo.txt").unwrap();
        assert_eq!(cached.data.as_ref(), &[1, 2, 3]);
        assert_eq!(cached.mime_type, "text/plain");
    }

    #[test]
    fn test_attr_cache() {
        let cache = FileCache::new(FileCacheConfig::default());

        let attr = CachedAttr {
            size: 100,
            is_dir: false,
            mime_type: Some("text/plain".to_string()),
            mtime: 1234567890,
        };

        cache.put_attr("/foo.txt", attr.clone());

        let cached = cache.get_attr("/foo.txt").unwrap();
        assert_eq!(cached.size, 100);
        assert!(!cached.is_dir);
    }

    #[test]
    fn test_dir_cache() {
        let cache = FileCache::new(FileCacheConfig::default());

        let entries = vec![
            CachedDirEntry {
                name: "file.txt".to_string(),
                is_dir: false,
            },
            CachedDirEntry {
                name: "subdir".to_string(),
                is_dir: true,
            },
        ];

        cache.put_dir("/", entries.clone());

        let cached = cache.get_dir("/").unwrap();
        assert_eq!(cached.len(), 2);
        assert_eq!(cached[0].name, "file.txt");
    }

    #[test]
    fn test_invalidate() {
        let cache = FileCache::new(FileCacheConfig::default());

        let content = CachedContent {
            data: Arc::new(vec![1, 2, 3]),
            mime_type: "text/plain".to_string(),
        };

        cache.put_content("/foo.txt", content);
        assert!(cache.get_content("/foo.txt").is_some());

        cache.invalidate("/foo.txt");
        assert!(cache.get_content("/foo.txt").is_none());
    }

    #[test]
    fn test_invalidate_all() {
        let cache = FileCache::new(FileCacheConfig::default());

        cache.put_content(
            "/a.txt",
            CachedContent {
                data: Arc::new(vec![1]),
                mime_type: "text/plain".to_string(),
            },
        );
        cache.put_content(
            "/b.txt",
            CachedContent {
                data: Arc::new(vec![2]),
                mime_type: "text/plain".to_string(),
            },
        );

        cache.invalidate_all();

        assert!(cache.get_content("/a.txt").is_none());
        assert!(cache.get_content("/b.txt").is_none());
    }

    #[test]
    fn test_negative_cache() {
        let cache = FileCache::new(FileCacheConfig::default());

        assert!(!cache.is_negative("/nonexistent"));

        cache.put_negative("/nonexistent");
        assert!(cache.is_negative("/nonexistent"));

        // Invalidate should clear negative cache too
        cache.invalidate("/nonexistent");
        assert!(!cache.is_negative("/nonexistent"));
    }

    #[test]
    fn test_negative_cache_invalidate_all() {
        let cache = FileCache::new(FileCacheConfig::default());

        cache.put_negative("/a");
        cache.put_negative("/b");
        assert!(cache.is_negative("/a"));
        assert!(cache.is_negative("/b"));

        cache.invalidate_all();
        assert!(!cache.is_negative("/a"));
        assert!(!cache.is_negative("/b"));
    }

    #[test]
    fn test_normalize_key() {
        assert_eq!(FileCache::normalize_key(""), "/");
        assert_eq!(FileCache::normalize_key("/"), "/");
        assert_eq!(FileCache::normalize_key("foo"), "/foo");
        assert_eq!(FileCache::normalize_key("/foo"), "/foo");
        assert_eq!(FileCache::normalize_key("/foo/"), "/foo");
    }

    /// Simulates the cache operations that occur during create() followed by
    /// rename(). Verifies that invalidating the parent directory cache after
    /// create() allows subsequent lookups to find the new file.
    #[test]
    fn test_create_invalidates_parent_dir_cache() {
        let cache = FileCache::new(FileCacheConfig::default());

        // Parent directory has a cached listing without the new file
        let parent_entries = vec![CachedDirEntry {
            name: "existing.txt".to_string(),
            is_dir: false,
        }];
        cache.put_dir("/", parent_entries);

        // Simulate create(): cache the new file's attrs and invalidate parent
        let attr = CachedAttr {
            size: 0,
            is_dir: false,
            mime_type: None,
            mtime: 1234567890,
        };
        cache.put_attr("/new_file.txt", attr);

        // Invalidate parent directory cache (the fix)
        cache.invalidate("/");

        // Parent dir cache should be gone, forcing a re-fetch
        assert!(cache.get_dir("/").is_none());

        // But the new file's attrs should still be cached
        assert!(cache.get_attr("/new_file.txt").is_some());
    }

    /// Verifies that creating a file clears any negative cache entry for that
    /// path, preventing stale ENOENT responses on subsequent lookups.
    #[test]
    fn test_create_clears_negative_cache_via_parent_invalidation() {
        let cache = FileCache::new(FileCacheConfig::default());

        // A prior lookup marked /new_file.txt as non-existent
        cache.put_negative("/new_file.txt");
        assert!(cache.is_negative("/new_file.txt"));

        // Simulate create(): put_attr does NOT clear negative cache
        let attr = CachedAttr {
            size: 0,
            is_dir: false,
            mime_type: None,
            mtime: 1234567890,
        };
        cache.put_attr("/new_file.txt", attr);

        // The attr cache has the entry, so fetch_attr would find it before
        // checking negative cache — but after flush() invalidates the attr,
        // negative cache would cause ENOENT. Invalidate the path too.
        cache.invalidate("/new_file.txt");
        // Re-add just the attr (simulating what create does after the fix)
        let attr2 = CachedAttr {
            size: 0,
            is_dir: false,
            mime_type: None,
            mtime: 1234567890,
        };
        cache.put_attr("/new_file.txt", attr2);

        // Negative cache should be clear
        assert!(!cache.is_negative("/new_file.txt"));
        // Attr should be present
        assert!(cache.get_attr("/new_file.txt").is_some());
    }
}