luff 0.2.1

Print files with formatting
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
//! Security checks for path containment and output file protection
//!
//! Provides protection against path traversal attacks and detection of
//! output files to prevent infinite loops during directory walking.

use std::path::Path;
use std::time::{Duration, SystemTime};

#[cfg(unix)]
use std::os::unix::fs::MetadataExt;

/// Threshold for shell redirection detection (configurable via env var)
///
/// Default: 10ms (shell redirection is nearly instantaneous, <1ms typically)
/// Can be overridden via `LUFF_OUTPUT_PROTECTION_MS` environment variable
///
/// Conservative threshold: Legitimate empty files created by users will be
/// older than this. Shell redirects (e.g., `luff > output.txt`) create
/// the file instantaneously before the process starts.
///
/// Note: This function does NOT cache the value. This allows tests to
/// dynamically configure the threshold using `env::set_var` without being
/// locked into the value from the first call. The performance cost of
/// `env::var` is negligible here as this is typically called once per
/// walker initialization.
pub fn output_protection_threshold() -> Duration {
    std::env::var("LUFF_OUTPUT_PROTECTION_MS")
        .ok()
        .and_then(|s| s.parse().ok())
        .map_or_else(|| Duration::from_millis(10), Duration::from_millis)
}

/// Check if a path is within a given root directory
///
/// This prevents path traversal attacks by ensuring a path doesn't
/// escape a designated root directory.
///
/// # Arguments
///
/// * `path` - The path to check
/// * `root` - The root directory that should contain the path
///
/// # Returns
///
/// `true` if path is within root, `false` otherwise
#[must_use]
pub fn is_within_root(path: &Path, root: &Path) -> bool {
    let normalized_path = super::normalization::normalize_path(path);
    let normalized_root = super::normalization::normalize_path(root);

    // Defense-in-depth: even though normalize_path now maps "" → ".",
    // guard against empty roots explicitly. `Path::starts_with("")` is
    // vacuously true for all paths, which would be a traversal bypass.
    // This also catches the "." case where component-wise starts_with
    // doesn't behave as expected (Normal("src") != CurDir).
    if normalized_root.as_ref() == Path::new(".") || normalized_root.as_ref().as_os_str().is_empty()
    {
        return !normalized_path.as_ref().is_absolute()
            && !normalized_path.as_ref().starts_with("..");
    }

    normalized_path.starts_with(normalized_root.as_ref())
}

/// Check if a canonical path is within a canonical root directory
///
/// This is an optimized version of `is_within_root` for paths that have
/// already been canonicalized (symlinks resolved, absolute paths).
/// Avoids redundant normalization.
///
/// # Preconditions
///
/// Both paths must be absolute and canonical. In debug builds, this is
/// verified with assertions.
///
/// # Arguments
///
/// * `canonical_path` - The canonical path to check
/// * `canonical_root` - The canonical root directory
///
/// # Returns
///
/// `true` if `canonical_path` is within `canonical_root`, `false` otherwise
///
/// # Panics
///
/// In debug builds, panics if either path is not absolute or contains
/// `.` or `..` components (indicating it's not canonical).
#[must_use]
pub fn is_canonical_within_root(canonical_path: &Path, canonical_root: &Path) -> bool {
    debug_assert!(
        canonical_path.is_absolute(),
        "canonical_path must be absolute, got: {}",
        canonical_path.display()
    );
    debug_assert!(
        canonical_root.is_absolute(),
        "canonical_root must be absolute, got: {}",
        canonical_root.display()
    );

    // In debug builds, verify paths are actually canonical (no . or ..)
    debug_assert!(
        !canonical_path.components().any(|c| matches!(
            c,
            std::path::Component::CurDir | std::path::Component::ParentDir
        )),
        "canonical_path contains . or .. components: {}",
        canonical_path.display()
    );
    debug_assert!(
        !canonical_root.components().any(|c| matches!(
            c,
            std::path::Component::CurDir | std::path::Component::ParentDir
        )),
        "canonical_root contains . or .. components: {}",
        canonical_root.display()
    );

    canonical_path.starts_with(canonical_root)
}

/// File identity for comparing if a file is the same as stdout
///
/// On Unix, this uses (device, inode) pair which uniquely identifies
/// a file within a filesystem. On other platforms, this type exists for
/// API uniformity but cannot be constructed — `get_stdout_identity()`
/// returns `None` and `from_metadata()` is not available.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct FileIdentity {
    /// Device ID (major/minor number) for file identification
    ///
    /// Combined with `ino`, uniquely identifies a file across the filesystem.
    /// This is part of the Unix stat structure and prevents false positives
    /// when comparing files across different devices.
    #[cfg(unix)]
    pub dev: u64,

    /// Inode number for file identification
    ///
    /// Combined with `dev`, uniquely identifies a file within a filesystem.
    /// This is the primary mechanism for detecting when stdout is redirected
    /// to a file in the scanned directory.
    #[cfg(unix)]
    pub ino: u64,

    /// Prevent external construction on non-Unix platforms.
    ///
    /// Without this, `FileIdentity` would be a public zero-field struct on
    /// Windows, constructible as `FileIdentity {}` by any downstream code,
    /// with `PartialEq` vacuously returning `true` for all instances —
    /// semantically wrong for a file identity type.
    #[cfg(not(unix))]
    _sealed: (),
}

impl FileIdentity {
    /// Create a new file identity from metadata
    #[cfg(unix)]
    #[must_use]
    pub fn from_metadata(metadata: &std::fs::Metadata) -> Self {
        Self {
            dev: metadata.dev(),
            ino: metadata.ino(),
        }
    }
}

/// Get the file identity for stdout
///
/// Returns `None` if stdout is not a regular file (e.g., terminal, pipe)
/// or if the identity cannot be determined.
///
/// # Platform Support
///
/// - **Unix**: Uses `fstat` on file descriptor 1 (stdout) to get the exact device/inode
///   of the redirection target. This is robust against symlinks and platform quirks
///   (like macOS `/dev/stdout` behavior).
/// - **Windows**: Not supported (returns None)
#[must_use]
pub fn get_stdout_identity() -> Option<FileIdentity> {
    #[cfg(unix)]
    {
        use std::io::IsTerminal;
        use std::os::unix::io::AsFd;

        // If stdout is a terminal, we don't need to protect against it
        // (we aren't writing to a file in the directory tree)
        if std::io::stdout().is_terminal() {
            return None;
        }

        // Use fstat directly on the file descriptor (1) to get the true identity
        // of the output stream. This avoids issues with path-based lookups
        // (like /dev/stdout) which can be unreliable on macOS/BSD.
        let stdout = std::io::stdout();
        match luff_sys::get_fd_identity(stdout.as_fd()) {
            Ok((dev, ino)) => {
                log::debug!("Identified stdout via fstat: dev={dev}, ino={ino}");
                Some(FileIdentity { dev, ino })
            }
            Err(e) => {
                log::debug!("Failed to get stdout identity via fstat: {e}");
                None
            }
        }
    }

    #[cfg(not(unix))]
    {
        None
    }
}

/// Check if a file path refers to the same file as stdout
///
/// Uses a two-tier detection strategy for robustness:
/// 1. **Primary (Unix)**: Inode comparison - if inodes match, definitely skip
/// 2. **Secondary (all platforms)**: Time-based heuristic - catches edge cases
///
/// This approach ensures the heuristic acts as a fallback when:
/// - Inode comparison is unavailable (non-Unix platforms)
/// - Stdout is redirected to a pipe/buffer (test environments)
/// - File system doesn't support inodes properly
///
/// # Arguments
///
/// * `path` - Path to the file to check
/// * `stdout_identity` - Pre-computed stdout identity (optimization)
///
/// # Returns
///
/// `true` if the file is (likely) being written to by stdout redirection
#[must_use]
pub fn is_output_file(path: &Path, stdout_identity: Option<&FileIdentity>) -> bool {
    // Suppress unused-variable warning on non-unix targets where `stdout_identity`
    // is not consumed by the inode-comparison tier. Without this, cross-compilation
    // to Windows would produce a warning that CI (Linux/macOS only) never catches.
    #[cfg(not(unix))]
    let _ = stdout_identity;

    // Single stat(2) call for both the inode check and the heuristic fallback.
    let metadata = match std::fs::metadata(path) {
        Ok(m) => m,
        Err(e) => {
            log::debug!("Failed to get metadata for {}: {e}", path.display());
            return false;
        }
    };

    // Tier 1 (Unix): Inode comparison — authoritative when available
    #[cfg(unix)]
    {
        if let Some(stdout_id) = stdout_identity {
            let file_id = FileIdentity::from_metadata(&metadata);
            if file_id == *stdout_id {
                log::debug!("Skipping output file (inode match): {}", path.display());
                return true;
            }
            // Inodes don't match — fall through to heuristic.
            // stdout might be a pipe/buffer, so the heuristic can still help.
        }
    }

    // Tier 2 (all platforms): Time-based heuristic for edge cases.
    // Skip recently created empty files (shell redirection pattern).

    // Must be empty
    if metadata.len() != 0 {
        return false;
    }

    // Must be very recently created
    if let Ok(modified) = metadata.modified() {
        if let Ok(elapsed) = SystemTime::now().duration_since(modified) {
            let threshold = output_protection_threshold();
            if elapsed < threshold {
                log::debug!(
                    "Skipping potential output file (heuristic): {} (age: {:?}, threshold: {:?})",
                    path.display(),
                    elapsed,
                    threshold
                );
                return true;
            }
        }
    }

    // Empty but old — not a shell redirect
    false
}

#[cfg(test)]
mod tests {
    use super::*;
    use std::fs;
    use tempfile::TempDir;

    #[test]
    fn test_is_within_root() {
        let root = Path::new("/home/user/project");
        let path = Path::new("/home/user/project/src/main.rs");
        assert!(is_within_root(path, root));

        let outside = Path::new("/home/user/other/file.txt");
        assert!(!is_within_root(outside, root));
    }

    #[test]
    fn test_is_within_root_dot_does_not_match_everything() {
        // Regression: normalize_path(".") used to produce "" (empty PathBuf),
        // and Path::starts_with("") is vacuously true. This allowed any path
        // to be considered "within" a root of ".".
        assert!(!is_within_root(
            Path::new("../../etc/passwd"),
            Path::new(".")
        ));
        assert!(!is_within_root(Path::new("/etc/passwd"), Path::new(".")));

        // Paths genuinely within "." should still work
        assert!(is_within_root(Path::new("./src/main.rs"), Path::new(".")));
        assert!(is_within_root(Path::new("src/main.rs"), Path::new(".")));
    }

    #[test]
    fn test_is_within_root_empty_does_not_match_everything() {
        // Regression: an empty root would cause Path::starts_with("") to be
        // vacuously true, allowing any path to be considered "within" root.
        assert!(!is_within_root(
            Path::new("../../etc/passwd"),
            Path::new("")
        ));
        assert!(!is_within_root(Path::new("/etc/passwd"), Path::new("")));

        // Paths genuinely within current directory should still work
        assert!(is_within_root(Path::new("src/main.rs"), Path::new("")));
    }

    #[test]
    fn test_is_within_root_dot_self() {
        // "." is within "."
        assert!(is_within_root(Path::new("."), Path::new(".")));
    }

    #[test]
    fn test_is_canonical_within_root() {
        let temp = TempDir::new().unwrap();
        let subdir = temp.path().join("subdir");
        fs::create_dir(&subdir).unwrap();
        let file = subdir.join("test.txt");
        fs::write(&file, "test").unwrap();

        let canonical_root = temp.path().canonicalize().unwrap();
        let canonical_file = file.canonicalize().unwrap();

        assert!(is_canonical_within_root(&canonical_file, &canonical_root));
    }

    #[cfg(unix)]
    #[test]
    fn test_file_identity_equality() {
        let id1 = FileIdentity { dev: 1, ino: 100 };
        let id2 = FileIdentity { dev: 1, ino: 100 };
        let id3 = FileIdentity { dev: 1, ino: 101 };

        assert_eq!(id1, id2);
        assert_ne!(id1, id3);
    }

    #[test]
    fn test_is_output_file_with_empty_recent_file() {
        let temp = TempDir::new().unwrap();
        let file = temp.path().join("recent_empty.txt");
        fs::write(&file, "").unwrap();

        // Should detect as potential output file (heuristic)
        let result = is_output_file(&file, None);
        assert!(result, "Recently created empty file should be detected");
    }

    #[test]
    fn test_is_output_file_with_non_empty_recent_file() {
        let temp = TempDir::new().unwrap();
        let file = temp.path().join("recent_nonempty.txt");
        fs::write(&file, "content").unwrap();

        // Should NOT detect (has content)
        let result = is_output_file(&file, None);
        assert!(!result, "Non-empty file should not be detected");
    }

    #[test]
    fn test_get_stdout_identity_when_terminal() {
        // When running tests, stdout is typically a terminal
        // In that case, get_stdout_identity should return None
        use std::io::IsTerminal;

        if std::io::stdout().is_terminal() {
            let identity = get_stdout_identity();
            assert_eq!(
                identity, None,
                "Should return None when stdout is a terminal"
            );
        }
    }

    #[test]
    fn test_is_output_file_fallback_to_heuristic() {
        use std::fs;
        use tempfile::TempDir;

        let temp = TempDir::new().unwrap();
        let file = temp.path().join("empty_recent.txt");
        fs::write(&file, "").unwrap();

        // Even with a non-matching stdout identity, heuristic should catch it
        #[cfg(unix)]
        {
            let fake_identity = FileIdentity {
                dev: 999_999,
                ino: 999_999,
            };
            let result = is_output_file(&file, Some(&fake_identity));
            assert!(
                result,
                "Heuristic should catch empty recent file even when inode doesn't match"
            );
        }

        // Non-Unix should also work
        #[cfg(not(unix))]
        {
            let result = is_output_file(&file, None);
            assert!(result, "Heuristic should catch empty recent file");
        }
    }
}