Skip to content

Commit a8e82b5

Browse files
committed
Add an Option To Preserve Parent Modification Time
Adds a `--parent-mtime`/`-m` option to restore the `mtime` of a directory to the value prior to creating a clone. Resolves #9
1 parent 587d039 commit a8e82b5

11 files changed

Lines changed: 282 additions & 37 deletions

File tree

.github/workflows/check.yaml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,9 @@ on:
66
pull_request_target:
77
workflow_dispatch:
88

9+
permissions:
10+
contents: read
11+
912
jobs:
1013
check:
1114
strategy:

Makefile

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ CFLAGS += \
1616
-Wno-unused-parameter \
1717
-Wno-gnu-conditional-omitted-operand \
1818
-Wno-macro-redefined \
19+
-Wimplicit-fallthrough \
1920
-Wp,-D_FORTIFY_SOURCE=3 \
2021
-fexceptions \
2122
-fpic \
@@ -113,7 +114,7 @@ clean: clean-coverage
113114

114115
report-coverage:
115116
mkdir -p build/private/coverage
116-
geninfo . -o build/private/coverage.info
117+
geninfo . -o build/private/coverage.info --ignore-errors path
117118
genhtml build/private/coverage.info -o build/private/coverage
118119

119120
PREFIX ?= /usr/local

README.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,12 @@ The following options are available:
119119
> from descending into directories that have a device number different than that
120120
> of the file from which the descent began.
121121
122+
**-m**, **--parent-mtime**
123+
124+
> Preserve the parent directory modification time (mtime) when a file is cloned.
125+
> Useful when working with backups or other programs that are sensitive to
126+
> directory changes.
127+
122128
**-**?, **--help**
123129

124130
> Print a summary of options and exit.

clone.c

Lines changed: 111 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
// Copyright © 2023 TTKB, LLC.
1+
// Copyright © 2023-2026 TTKB, LLC.
22
//
33
// Redistribution and use in source and binary forms, with or without
44
// modification, are permitted provided that the following conditions are met:
@@ -36,6 +36,7 @@
3636

3737
#include <err.h>
3838
#include <errno.h>
39+
#include <fcntl.h>
3940
#include <libgen.h>
4041
#include <stdio.h>
4142
#include <stdlib.h>
@@ -44,7 +45,7 @@
4445

4546
#include "clone.h"
4647

47-
int find_zero_file(const char* restrict path) {
48+
static int find_zero_file(const char* restrict path) {
4849
if (access(path, W_OK)) {
4950
return 1;
5051
}
@@ -97,7 +98,7 @@ char* tmp_name(const char* restrict path, char* restrict out, size_t size) {
9798
return out;
9899
}
99100

100-
int genfile_clone(const char* src, const char* dst) {
101+
static int genfile_clone(const char* src, const char* dst) {
101102
#if defined(__APPLE__)
102103
return clonefile(src, dst, 0);
103104
#elif defined(__FREEBSD__)
@@ -129,26 +130,97 @@ int genfile_clone(const char* src, const char* dst) {
129130
#endif
130131
}
131132

132-
int replace_with_clone(const char* src, const char* dst) {
133+
// get the parent directory mtime and return it and a file descriptor
134+
// for the directory. if return is 0, caller is responsible for closing
135+
// the returned fd
136+
static int dir_mtime(const char* path, int* fd_out, struct timespec* mtime_out) {
137+
char buffer[PATH_MAX] = { 0 };
138+
char* parent = dirname_r(path, buffer);
139+
if (!parent) {
140+
perror("dirname_r");
141+
return -1;
142+
}
143+
144+
int fd = open(parent, O_RDONLY);
145+
if (fd == -1) {
146+
perror("open parent dir");
147+
return -1;
148+
}
149+
150+
struct stat st;
151+
if (fstat(fd, &st) == -1) {
152+
perror("fstat parent dir");
153+
close(fd);
154+
return -1;
155+
}
156+
157+
*fd_out = fd;
158+
*mtime_out = st.st_mtimespec;
159+
return 0;
160+
}
161+
162+
// restore the provided mtime to the provided fd. fd is closed
163+
// before the function returns
164+
static void restore_dir_mtime(int fd, struct timespec mtime) {
165+
struct timespec times[2] = {
166+
// omit atime
167+
{ .tv_sec = 0, .tv_nsec = UTIME_OMIT },
168+
mtime,
169+
};
170+
if (futimens(fd, times) == -1) {
171+
switch (errno) {
172+
case EPERM:
173+
fprintf(stderr, "Warning: cannot preserve parent mtime, permission denied\n");
174+
break;
175+
case EROFS:
176+
fprintf(stderr, "Warning: cannot preserve parent mtime, filesystem is read-only\n");
177+
break;
178+
default:
179+
perror("Warning: futimens");
180+
break;
181+
}
182+
}
183+
close(fd);
184+
}
185+
186+
#ifdef DEBUG
187+
#define COPYFILE_DEBUG (1<<31)
188+
#else
189+
#define COPYFILE_DEBUG (0)
190+
#endif
191+
192+
int replace_with_clone(const char* src, const char* dst, bool preserve_parent_mtime) {
193+
int parent_fd = -1;
194+
struct timespec saved_mtime = { 0 };
195+
196+
if (preserve_parent_mtime &&
197+
dir_mtime(dst, &parent_fd, &saved_mtime) == -1) {
198+
return errno;
199+
}
200+
201+
int result = 0;
202+
133203
char path[PATH_MAX] = { 0 };
134204
if (!tmp_name(dst, path, PATH_MAX)) {
135-
return errno;
205+
result = errno;
206+
goto cleanup;
136207
}
137208

138209
errno = 0;
139-
int result = genfile_clone(src, path);
210+
result = genfile_clone(src, path);
140211

141212
if (result) {
142213
perror("could not clonefile");
143214
unlink(path); // if it exists
144-
return result;
215+
goto cleanup;
145216
}
146217

147218
if (find_zero_file(path)) {
148219
fprintf(stderr,
149220
"invalid file created by clonefile(2)\n");
150221
unlink(path);
151-
return ENOENT;
222+
result = ENOENT;
223+
goto cleanup;
152224
}
153225

154226
#if defined(__APPLE__)
@@ -161,24 +233,26 @@ int replace_with_clone(const char* src, const char* dst) {
161233
if (check & COPYFILE_DATA) {
162234
perror("copyfile(3) should not copy data");
163235
unlink(path);
164-
return check;
236+
result = check;
237+
goto cleanup;
165238
}
166239

167240
result = copyfile(dst,
168241
path,
169242
NULL,
170-
COPYFILE_METADATA | (1<<31));
243+
COPYFILE_METADATA | COPYFILE_DEBUG);
171244
if (result) {
172245
perror("could not copy metadata");
173246
unlink(path);
174-
return result;
247+
goto cleanup;
175248
}
176249

177250
if (find_zero_file(path)) {
178251
fprintf(stderr,
179252
"invalid file created by copyfile(3)\n");
180253
unlink(path);
181-
return ENOENT;
254+
result = ENOENT;
255+
goto cleanup;
182256
}
183257
#else
184258
#error Operating system not supported
@@ -191,10 +265,14 @@ int replace_with_clone(const char* src, const char* dst) {
191265
if (result) {
192266
perror("could not replace existing file");
193267
unlink(path);
194-
return result;
268+
goto cleanup;
195269
}
196270

197-
return 0;
271+
cleanup:
272+
if (preserve_parent_mtime) {
273+
restore_dir_mtime(parent_fd, saved_mtime);
274+
}
275+
return result;
198276
}
199277

200278

@@ -211,14 +289,25 @@ int replace_with_link(const char* src, const char* dst) {
211289

212290
// returns a relative path to dst from src
213291
char* path_relative_to(const char* src, const char* dst) {
214-
char* real_src = realpath(src, NULL),
215-
* real_dst = realpath(dst, NULL),
216-
* orig_real_src = real_src,
292+
char* real_src = realpath(src, NULL);
293+
if (real_src == NULL) {
294+
fprintf(stderr, "%s could not be resolved to a canonical path.\n", src);
295+
return NULL;
296+
}
297+
298+
char* real_dst = realpath(dst, NULL);
299+
if (real_dst == NULL) {
300+
free(real_src);
301+
fprintf(stderr, "%s could not be resolved to a canonical path.\n", dst);
302+
return NULL;
303+
}
304+
305+
char* orig_real_src = real_src,
217306
* orig_real_dst = real_dst;
218307

219-
// consume root /
220-
real_src++;
221-
real_dst++;
308+
// consume root '/' if it exists
309+
if (*real_src == '/') real_src++;
310+
if (*real_dst == '/') real_dst++;
222311

223312
int depth = 0;
224313

@@ -275,6 +364,8 @@ char* path_relative_to(const char* src, const char* dst) {
275364
}
276365

277366
int replace_with_symlink(const char* src, const char* dst) {
367+
// must be called prior to unlink because realpath
368+
// is used. unlinking first removes the real path
278369
char* path = path_relative_to(dst, src);
279370

280371
// TODO: should this atomically move a tmp file instead of

clone.h

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
// Copyright © 2023 TTKB, LLC.
1+
// Copyright © 2023-2026 TTKB, LLC.
22
//
33
// Redistribution and use in source and binary forms, with or without
44
// modification, are permitted provided that the following conditions are met:
@@ -51,9 +51,28 @@
5151
/// returned by `clonefile(2)`, `copyfile(2)`, or `rename(2)`.
5252
///
5353
/// See also: `clonefile(2)`, `copyfile(2)`, or `rename(2)`
54-
int replace_with_clone(const char* src, const char* dst);
54+
int replace_with_clone(const char* src, const char* dst, bool preserve_parent_mtime);
5555

56+
/// replace_with_link
57+
///
58+
/// The `replae_with_link` function causes the link named `dst` to be
59+
/// replaced with a hardlink of `src`. `src` and `dst` must be on the same
60+
/// volume.
61+
///
62+
/// On success `dst` will be replaced and `replace_with_link` returns 0.
63+
///
64+
/// On failure `link(2)` will return an error.
65+
///
66+
/// See also: `link(2)`
5667
int replace_with_link(const char* src, const char* dst);
68+
69+
/// replace_with_symlink
70+
///
71+
/// The `replace_with_symlink` function causes the link named `dst` to be
72+
/// replaced with a symlink to `src`. The link will be a relative path from
73+
/// `dst` to `src`.
74+
///
75+
/// See also: `symlink(2)`
5776
int replace_with_symlink(const char* src, const char* dst);
5877

5978
#endif // __DEDUP_CLONE_H__

dedup.1

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -143,6 +143,10 @@ Prevent
143143
.Nm
144144
from descending into directories that have a device number different than that
145145
of the file from which the descent began.
146+
.It Fl m , Fl Fl parent-mtime
147+
Preserve the parent directory modification time (mtime) when a file is cloned.
148+
Useful when working with backups or other programs that are sensitive to
149+
directory changes.
146150
.It Fl ? , Fl Fl help
147151
Print a summary of options and exit.
148152
.El

dedup.c

Lines changed: 14 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
// Copyright © 2023 TTKB, LLC.
1+
// Copyright © 2023-2026 TTKB, LLC.
22
//
33
// Redistribution and use in source and binary forms, with or without
44
// modification, are permitted provided that the following conditions are met:
@@ -89,6 +89,7 @@ typedef struct DedupContext {
8989
bool dry_run;
9090
uint8_t verbosity;
9191
bool force;
92+
bool preserve_parent_mtime;
9293
ReplaceMode replace_mode;
9394
pthread_mutex_t metrics_mutex;
9495
pthread_mutex_t progress_mutex;
@@ -318,7 +319,6 @@ size_t deduplicate(AList* metadata_set, DedupContext* ctx) {
318319
if (ctx->dry_run) {
319320
printf("\tcloning to %s\n",
320321
fm->path);
321-
322322
ctx->saved += fm->size;
323323
continue;
324324
}
@@ -327,7 +327,8 @@ size_t deduplicate(AList* metadata_set, DedupContext* ctx) {
327327
switch (ctx->replace_mode) {
328328
case DEDUP_CLONE:
329329
result = replace_with_clone(origin->path,
330-
fm->path);
330+
fm->path,
331+
ctx->preserve_parent_mtime);
331332
break;
332333
case DEDUP_LINK:
333334
result = replace_with_link(origin->path,
@@ -374,7 +375,7 @@ size_t deduplicate(AList* metadata_set, DedupContext* ctx) {
374375
__attribute__((noreturn))
375376
static void usage(char* pgm, DedupContext* ctx) {
376377
fprintf(stderr,
377-
"%s\nusage: %s [-I pattern] [-t n] [-PVcnvx] [-d n] [file ...]\n\n"
378+
"%s\nusage: %s [-I pattern] [-t n] [-PVcmnvx] [-d n] [file ...]\n\n"
378379
"Options:\n"
379380
// " --ignore, -I pattern Exclude a pattern from being used as a clone\n"
380381
// " source or being replaced by a clone. This option\n"
@@ -390,6 +391,8 @@ static void usage(char* pgm, DedupContext* ctx) {
390391
" --no-progress, -P Do not display a progress bar.\n"
391392
" --threads, -t n The number of threads to use for file building\n"
392393
" lookup tables and replacing clones. Default: %d\n"
394+
" --parent-mtime, -m Preserve the mtime of any parent directory\n"
395+
" modified with a clone.\n"
393396
" --verbose, -v Increase verbosity. May be used multiple times.\n"
394397
" --version, -V Print the version and exit\n"
395398
// " --force, -f Don't preserve existing hardlinks.\n"
@@ -506,7 +509,8 @@ int main(int argc, char* argv[]) {
506509
.done = 0,
507510
.dry_run = false,
508511
.verbosity = 0,
509-
.force = 0,
512+
.force = false,
513+
.preserve_parent_mtime = false,
510514
.replace_mode = DEDUP_CLONE,
511515
.thread_count = cpu_count(),
512516
.metrics_mutex = PTHREAD_MUTEX_INITIALIZER,
@@ -525,6 +529,7 @@ int main(int argc, char* argv[]) {
525529
{ "depth", required_argument, NULL, 'd' },
526530
{ "link", no_argument, NULL, 'l' },
527531
{ "dry-run", no_argument, NULL, 'n' },
532+
{ "parent-mtime", no_argument, NULL, 'm' },
528533
{ "symlink", no_argument, NULL, 's' },
529534
{ "threads", required_argument, NULL, 't' },
530535
{ "verbose", no_argument, NULL, 'v' },
@@ -538,7 +543,7 @@ int main(int argc, char* argv[]) {
538543

539544
int ch = -1, t;
540545
short d;
541-
while ((ch = getopt_long(argc, argv, "I:PVc::d:fhlnst:vx?", options, NULL)) != -1) {
546+
while ((ch = getopt_long(argc, argv, "I:PVc::d:fhlmnst:vx?", options, NULL)) != -1) {
542547
switch (ch) {
543548
case 'I':
544549
fprintf(stderr, "-I is unimplemented\n");
@@ -567,6 +572,9 @@ int main(int argc, char* argv[]) {
567572
case 'l':
568573
dc.replace_mode = DEDUP_LINK;
569574
break;
575+
case 'm':
576+
dc.preserve_parent_mtime = true;
577+
break;
570578
case 'n':
571579
dc.dry_run = true;
572580
break;

0 commit comments

Comments
 (0)