mdtablefix 0.6.1

`mdtablefix` unb0rks and reflows Markdown tables so that each column has a uniform width. When the `--wrap` option is used, it also wraps paragraphs and list items to 80 columns.
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
//! The atomic swap: everything between a decided replacement and the rename
//! that puts it in place.
//!
//! [`write_and_swap`] writes the temporary file and hands it to
//! [`swap_into_place`], which applies the target's permissions, checks that the
//! target still holds the text it was replaced on the strength of, prepares the
//! destination on the platforms that need it, checks the target again, and
//! renames. The second check is the last comparison before the rename, so the
//! window a writer can be overwritten in is the rename alone. A failure after
//! the temporary file exists is cleaned up by [`remove_temporary_file`].
//!
//! Every function here takes the directory capability the caller already holds;
//! nothing in this module opens an ambient path.

use std::io::{self, Write};

use camino::{Utf8Path, Utf8PathBuf};
use cap_std::fs_utf8::{Dir, File, OpenOptions, Permissions};
use metrics::counter;
use tracing::{debug, trace};

/// Candidate temporary names to try before conceding that a stale temporary
/// file from an earlier killed run is in the way.
pub(super) const TEMP_FILE_ATTEMPTS: u32 = 16;

/// Writes `contents` to `temp_path`, applies `permissions`, and renames the
/// result over `path` once `path` still holds `expected`.
///
/// `expected` is the text the target was read as before this replacement was
/// decided, or `None` when the caller has nothing to compare against. The
/// comparison is made by [`swap_into_place`], after the temporary file is
/// written and synced, so the caller is not charged for the write when the
/// target has moved on: `Ok(false)` says so, and nothing is renamed.
pub(super) fn write_and_swap(
    directory: &Dir,
    temp_path: &Utf8Path,
    path: &Utf8Path,
    expected: Option<&str>,
    contents: &str,
    permissions: &Permissions,
    mut file: File,
) -> io::Result<bool> {
    file.write_all(contents.as_bytes())?;
    file.flush()?;
    debug!(bytes = contents.len(), "temporary file written");
    file.sync_all()?;
    debug!("temporary file synced");
    // Close the handle before renaming: Windows refuses to replace a
    // destination that another handle holds open without delete sharing.
    drop(file);
    swap_into_place(directory, temp_path, path, expected, permissions)
}

/// Applies `permissions` and renames `temp_path` over `path`.
///
/// The permissions go on the temporary file first, so the file never exists
/// under its final name with permissions the target never had, and a successful
/// rename leaves the new file with the target's permissions — read-only
/// included — without a second step that could fail after the swap. Only then
/// is the destination prepared, so the window in which it is writable is as
/// short as the platform allows; [`prepare_destination`] holds what only
/// Windows needs before the rename can be attempted at all, and
/// [`restore_destination`] undoes it when the swap does not complete.
///
/// `expected` is compared twice, and the two comparisons are not the same one.
/// The first is made here, after the temporary file is written and before the
/// destination is prepared, so a target that moved on is declined without the
/// destination being touched at all: on Windows, where the preparation clears a
/// read-only attribute, declining early is what keeps a declined swap from
/// writing to the target. The second is made after the preparation and
/// immediately before the rename, which is as late as the platform allows: it
/// is what catches a writer that lands in the window the swap cannot close,
/// where the rename would otherwise discard what that writer put there.
///
/// Returns `Ok(false)` when the target no longer holds `expected`, having
/// renamed nothing and having put back whatever the preparation changed.
fn swap_into_place(
    directory: &Dir,
    temp_path: &Utf8Path,
    path: &Utf8Path,
    expected: Option<&str>,
    permissions: &Permissions,
) -> io::Result<bool> {
    directory
        .set_permissions(temp_path, permissions.clone())
        .inspect(|()| debug!("target mode applied"))?;
    if !holds_expected(directory, path, expected)? {
        debug!("target changed since it was read; leaving it alone");
        return Ok(false);
    }
    let prepared = prepare_destination(directory, path, permissions)?;
    // The window no rename closes: no platform's rename compares contents, so a
    // writer that lands between the comparison above and the one below would be
    // overwritten rather than declined. A test arms this seam to put one there;
    // nothing else runs it.
    #[cfg(test)]
    competing_writer_seam::run(directory, path);
    if !holds_expected(directory, path, expected)? {
        debug!("target changed before the swap; leaving it alone");
        undo_preparation(directory, path, prepared);
        return Ok(false);
    }
    if let Err(error) = rename_over_target(directory, temp_path, path) {
        undo_preparation(directory, path, prepared);
        return Err(error);
    }
    debug!("target replaced");
    Ok(true)
}

/// Puts back what [`prepare_destination`] changed, when it changed anything.
///
/// Shared by the two ways a swap stops short of the rename — a target that
/// moved on, and a rename that failed — so the rollback cannot differ between
/// them.
fn undo_preparation(directory: &Dir, path: &Utf8Path, prepared: Option<Permissions>) {
    if let Some(original) = prepared {
        restore_destination(directory, path, &original);
    }
}

/// Whether `path` still reads as `expected`; always true when it is `None`.
///
/// The read goes through the caller's directory capability, like every other
/// operation here, and a target that cannot be read back at all is an error
/// rather than a mismatch: a caller that asked for a conditional replacement
/// must not be told it succeeded, or that the condition failed, when the
/// question could not be put.
fn holds_expected(directory: &Dir, path: &Utf8Path, expected: Option<&str>) -> io::Result<bool> {
    let Some(expected) = expected else {
        return Ok(true);
    };
    if directory.read_to_string(path)? == expected {
        trace!("target still holds the text it was read as");
        Ok(true)
    } else {
        Ok(false)
    }
}

/// Prepares `path` for the rename that will replace it.
///
/// Returns the permissions to put back if the swap does not complete, or `None`
/// when the platform needs no preparation. Windows is the platform that does: a
/// destination carrying `FILE_ATTRIBUTE_READONLY` cannot be replaced. The
/// rename fails with `ERROR_ACCESS_DENIED`, and the flags that
/// `SetFileInformationByHandle` accepts for a rename have no counterpart to the
/// `FILE_DISPOSITION_FLAG_IGNORE_READONLY_ATTRIBUTE` that the delete path relies
/// on, so the attribute itself has to be cleared for the duration of the swap,
/// through the same directory capability as every other operation here.
#[cfg(windows)]
fn prepare_destination(
    directory: &Dir,
    path: &Utf8Path,
    permissions: &Permissions,
) -> io::Result<Option<Permissions>> {
    if !permissions.readonly() {
        return Ok(None);
    }
    let mut writable = permissions.clone();
    writable.set_readonly(false);
    directory.set_permissions(path, writable)?;
    debug!("destination read-only attribute cleared");
    Ok(Some(permissions.clone()))
}

/// The preparation a platform whose rename needs none performs.
///
/// The signature matches the Windows implementation so that the swap has one
/// body. Clearing the read-only flag here would not serve the rename, and on
/// Unix it would rewrite the target's mode for the duration of the swap:
/// `set_readonly(false)` clears all three write bits, so a `0o444` file would
/// briefly become `0o666`.
#[cfg(not(windows))]
#[expect(
    clippy::unnecessary_wraps,
    reason = "the signature must match the Windows preparation, which can fail"
)]
fn prepare_destination(
    _directory: &Dir,
    _path: &Utf8Path,
    _permissions: &Permissions,
) -> io::Result<Option<Permissions>> {
    Ok(None)
}

/// Puts the destination's original permissions back after a swap that did not
/// complete.
///
/// Best effort by design: a replacement that failed or was declined must not
/// leave the target writable as its only lasting effect, and a failure to
/// restore must not mask the reason the swap stopped short, so it is traced
/// rather than returned. The category is the `io::ErrorKind`, which is bounded,
/// rather than an error string or a path.
#[cfg(windows)]
fn restore_destination(directory: &Dir, path: &Utf8Path, original: &Permissions) {
    if let Err(error) = directory.set_permissions(path, original.clone()) {
        debug!(
            error_category = ?error.kind(),
            "destination mode restore failed"
        );
    }
}

/// The rollback of a preparation that no platform other than Windows makes.
#[cfg(not(windows))]
fn restore_destination(_directory: &Dir, _path: &Utf8Path, _original: &Permissions) {}

/// Renames `temp_path` over `path`.
///
/// A test can arm [`rename_failure_seam`] to make this fail deterministically.
/// That seam is the only way to reach the rollback in [`swap_into_place`]: a
/// rename that a test can make fail for real fails before the destination is
/// ever prepared, so the rollback would never have anything to put back.
fn rename_over_target(directory: &Dir, temp_path: &Utf8Path, path: &Utf8Path) -> io::Result<()> {
    #[cfg(test)]
    if rename_failure_seam::take() {
        return Err(io::Error::other("the rename failure seam is armed"));
    }
    directory.rename(temp_path, directory, path)
}

/// Removes the temporary file left behind by a failed replacement.
///
/// Windows refuses to delete a file carrying `FILE_ATTRIBUTE_READONLY`, and by
/// the time the swap runs the temporary file already carries the target's
/// permissions, so the attribute is cleared first. Clearing it is best effort:
/// the removal below reports its own failure, and a name that survives is
/// retried past by the next run.
pub(super) fn remove_temporary_file(directory: &Dir, temp_path: &Utf8Path) -> io::Result<()> {
    #[cfg(test)]
    if cleanup_failure_seam::take() {
        return Err(io::Error::other("the cleanup failure seam is armed"));
    }
    #[cfg(windows)]
    {
        if let Ok(metadata) = directory.metadata(temp_path) {
            let mut permissions = metadata.permissions();
            if permissions.readonly() {
                permissions.set_readonly(false);
                if let Err(error) = directory.set_permissions(temp_path, permissions) {
                    debug!(
                        error_category = ?error.kind(),
                        "temporary file mode could not be cleared"
                    );
                }
            }
        }
    }
    directory.remove_file(temp_path)
}

/// Creates a new temporary file beside `path` inside `directory`.
///
/// The name carries the process id and the attempt number, and the file is
/// created exclusively, so a temporary file left behind by a killed run only
/// costs one retry: the attempt advances and the next candidate is tried.
pub(super) fn create_temporary_file(
    directory: &Dir,
    path: &Utf8Path,
) -> io::Result<(Utf8PathBuf, File)> {
    let mut options = OpenOptions::new();
    options.write(true).create_new(true);
    for attempt in 0..TEMP_FILE_ATTEMPTS {
        let temp_path = temporary_path(path, attempt);
        match directory.open_with(&temp_path, &options) {
            Ok(file) => {
                debug!(attempt, "temporary file created");
                return Ok((temp_path, file));
            }
            Err(error) if error.kind() == io::ErrorKind::AlreadyExists => {
                trace!(
                    attempt,
                    reason = "already_exists",
                    "temporary name rejected"
                );
                counter!("mdtablefix_io_temporary_name_collisions_total").increment(1);
            }
            Err(error) => return Err(error),
        }
    }
    counter!("mdtablefix_io_temporary_name_exhausted_total").increment(1);
    Err(io::Error::new(
        io::ErrorKind::AlreadyExists,
        format!("no free temporary file name beside {path} after {TEMP_FILE_ATTEMPTS} attempts"),
    ))
}

/// Builds candidate temporary path `attempt` beside `path`.
///
/// The name is a pure function of the target, the process id, and the attempt,
/// so a caller can predict and occupy a candidate without reaching into the
/// process. Any parent components are preserved, so the temporary file always
/// lands in the target's own directory and the rename never crosses a
/// directory.
pub(super) fn temporary_path(path: &Utf8Path, attempt: u32) -> Utf8PathBuf {
    let name = format!(
        "{}.mdtablefix-{}-{attempt}.tmp",
        path.file_name().unwrap_or_default(),
        std::process::id()
    );
    path.with_file_name(name)
}

#[cfg(test)]
pub(crate) mod rename_failure_seam {
    //! A test-only seam that fails the rename half of the swap.
    //!
    //! Every rename a test can be made to fail for real fails before the
    //! destination is prepared, so the rollback in `swap_into_place` is
    //! otherwise unreachable. The arming is per-thread, because the tests that
    //! use it drive the swap on the thread that armed it, and it is undone when
    //! the value [`arm`] returns is dropped, so a failing assertion cannot
    //! leave the failure armed for whatever runs next on that thread.

    use std::cell::Cell;

    thread_local! {
        /// Whether this thread's next rename must fail.
        static ARMED: Cell<bool> = const { Cell::new(false) };
    }

    /// Arms the seam until the returned value is dropped.
    pub(crate) fn arm() -> Armed {
        ARMED.with(|armed| armed.set(true));
        Armed
    }

    /// Disarms the seam when dropped.
    pub(crate) struct Armed;

    impl Drop for Armed {
        fn drop(&mut self) { ARMED.with(|armed| armed.set(false)); }
    }

    /// Consumes the arming, reporting whether this rename must fail.
    ///
    /// One-shot by design: arming fails exactly one swap, so a test that
    /// triggers more than one rename cannot have the seam fire twice.
    pub(crate) fn take() -> bool { ARMED.with(|armed| armed.replace(false)) }
}

#[cfg(test)]
pub(crate) mod cleanup_failure_seam {
    //! A test-only seam that fails the removal of a temporary file.
    //!
    //! The cleanup after a failed replacement is otherwise reachable only
    //! through a real removal failure, which no test can force on every
    //! platform: a directory target fails the swap before a temporary file is
    //! named, and a permission bit is ignored by a run as root. The arming is
    //! per-thread, because the tests that use it drive the replacement on the
    //! thread that armed it, and it is undone when the value [`arm`] returns is
    //! dropped, so a failing assertion cannot leave the removal failing for
    //! whatever runs next on that thread.

    use std::cell::Cell;

    thread_local! {
        /// Whether this thread's next removal must fail.
        static ARMED: Cell<bool> = const { Cell::new(false) };
    }

    /// Arms the seam until the returned value is dropped.
    pub(crate) fn arm() -> Armed {
        ARMED.with(|armed| armed.set(true));
        Armed
    }

    /// Disarms the seam when dropped.
    pub(crate) struct Armed;

    impl Drop for Armed {
        fn drop(&mut self) { ARMED.with(|armed| armed.set(false)); }
    }

    /// Consumes the arming, reporting whether this removal must fail.
    ///
    /// One-shot by design: arming fails exactly one removal, so a test that
    /// triggers more than one cleanup cannot have the seam fire twice.
    pub(crate) fn take() -> bool { ARMED.with(|armed| armed.replace(false)) }
}

#[cfg(test)]
pub(crate) mod competing_writer_seam {
    //! A test-only seam that lets a case land another writer inside the swap.
    //!
    //! The window it opens is the one no platform lets the swap close: no
    //! rename compares contents, so a writer that lands between the swap's last
    //! comparison and its rename is overwritten by it. Without the seam a test
    //! can only reach that window by racing the scheduler, and a case that wins
    //! the race on one machine loses it on the next. Here the write is handed
    //! the directory capability and the target the swap is working with, so it
    //! lands in the window deterministically and through the same capability as
    //! every other operation.
    //!
    //! The arming is per-thread, because the tests that use it drive the swap on
    //! the thread that armed it, and it is undone when the value [`arm`] returns
    //! is dropped, so a failing assertion cannot leave an intrusion armed for
    //! whatever runs next on that thread.

    use std::cell::Cell;

    use super::{Dir, Utf8Path};

    /// The write an armed seam runs before the swap's final comparison.
    ///
    /// Boxed so the arming can hold one concrete closure of any shape, and
    /// aliased so the thread-local below states what it holds rather than the
    /// shape of a boxed trait object.
    type Intrusion = Box<dyn FnOnce(&Dir, &Utf8Path)>;

    thread_local! {
        /// The write this thread's next swap must run before its final comparison.
        static ARMED: Cell<Option<Intrusion>> = const { Cell::new(None) };
    }

    /// Arms the seam with the write to run inside the swap, until the returned
    /// value is dropped.
    ///
    /// The write is given the capability and the target as arguments, so the
    /// arming captures no path and no handle of its own.
    pub(crate) fn arm(intrude: impl FnOnce(&Dir, &Utf8Path) + 'static) -> Armed {
        ARMED.with(|armed| armed.set(Some(Box::new(intrude))));
        Armed
    }

    /// Disarms the seam when dropped.
    pub(crate) struct Armed;

    impl Drop for Armed {
        fn drop(&mut self) { ARMED.with(|armed| armed.set(None)); }
    }

    /// Runs the armed intrusion, if any, exactly once.
    ///
    /// One-shot by design: an intrusion arms exactly one swap, so a test that
    /// triggers more than one swap cannot have the seam fire twice.
    pub(crate) fn run(directory: &Dir, path: &Utf8Path) {
        if let Some(intrude) = ARMED.with(Cell::take) {
            intrude(directory, path);
        }
    }
}