openMSX
FilePool.cc
Go to the documentation of this file.
1 #include "FilePool.hh"
2 #include "File.hh"
3 #include "FileException.hh"
4 #include "FileContext.hh"
5 #include "FileOperations.hh"
6 #include "TclObject.hh"
7 #include "ReadDir.hh"
8 #include "Date.hh"
9 #include "CommandException.hh"
10 #include "Display.hh"
11 #include "EventDistributor.hh"
12 #include "CliComm.hh"
13 #include "Reactor.hh"
14 #include "Timer.hh"
15 #include "ranges.hh"
16 #include "sha1.hh"
17 #include <fstream>
18 #include <memory>
19 
20 using std::ifstream;
21 using std::ofstream;
22 using std::string;
23 using std::vector;
24 
25 namespace openmsx {
26 
27 class Sha1SumCommand final : public Command
28 {
29 public:
30  Sha1SumCommand(CommandController& commandController, FilePool& filePool);
31  void execute(span<const TclObject> tokens, TclObject& result) override;
32  string help(const vector<string>& tokens) const override;
33  void tabCompletion(vector<string>& tokens) const override;
34 private:
35  FilePool& filePool;
36 };
37 
38 
39 const char* const FILE_CACHE = "/.filecache";
40 
41 static string initialFilePoolSettingValue()
42 {
43  TclObject result;
44 
45  for (auto& p : systemFileContext().getPaths()) {
46  result.addListElement(
47  makeTclDict("-path", FileOperations::join(p, "systemroms"),
48  "-types", "system_rom"),
49  makeTclDict("-path", FileOperations::join(p, "software"),
50  "-types", "rom disk tape"));
51  }
52  return result.getString().str();
53 }
54 
56  : filePoolSetting(
57  controller, "__filepool",
58  "This is an internal setting. Don't change this directly, "
59  "instead use the 'filepool' command.",
60  initialFilePoolSettingValue())
61  , reactor(reactor_)
62  , quit(false)
63 {
64  filePoolSetting.attach(*this);
66  try {
67  readSha1sums();
68  } catch (MSXException&) {
69  // ignore, probably .filecache doesn't exist yet
70  }
71  needWrite = false;
72 
73  sha1SumCommand = std::make_unique<Sha1SumCommand>(controller, *this);
74 }
75 
77 {
78  if (needWrite) {
79  writeSha1sums();
80  }
82  filePoolSetting.detach(*this);
83 }
84 
85 void FilePool::insert(const Sha1Sum& sum, time_t time, const string& filename)
86 {
87  auto it = ranges::upper_bound(pool, sum, ComparePool());
88  stringBuffer.push_back(filename);
89  pool.emplace(it, sum, time, stringBuffer.back().c_str());
90  needWrite = true;
91 }
92 
93 void FilePool::remove(Pool::iterator it)
94 {
95  pool.erase(it);
96  needWrite = true;
97 }
98 
99 // Change the sha1sum of the element pointed to by 'it' into 'newSum'.
100 // Also re-arrange the items so that pool remains sorted on sha1sum. Internally
101 // this method doesn't actually sort, it merely rotates the elements.
102 // Returns false if the new position is before (or at) the old position.
103 // Returns true if the new position is after the old position.
104 bool FilePool::adjust(Pool::iterator it, const Sha1Sum& newSum)
105 {
106  needWrite = true;
107  auto newIt = ranges::upper_bound(pool, newSum, ComparePool());
108  it->sum = newSum; // update sum
109  if (newIt > it) {
110  // move to back
111  rotate(it, it + 1, newIt);
112  return true;
113  } else {
114  if (newIt < it) {
115  // move to front
116  rotate(newIt, it, it + 1);
117  } else {
118  // (unlikely) sha1sum has changed, but after
119  // resorting item would remain in the same
120  // position
121  }
122  return false;
123  }
124 }
125 
126 time_t FilePool::PoolEntry::getTime()
127 {
128  if (time == time_t(-1)) {
129  time = Date::fromString(timeStr);
130  }
131  return time;
132 }
133 
134 void FilePool::PoolEntry::setTime(time_t t)
135 {
136  time = t;
137  timeStr = nullptr;
138 }
139 
140 static bool parse(char* line, char* line_end,
141  Sha1Sum& sha1, const char*& timeStr, const char*& filename)
142 {
143  if ((line_end - line) <= 68) return false; // minumum length (only filename is variable)
144 
145  // only perform quick sanity check on date/time format
146  if (line[40] != ' ') return false; // two space between sha1sum and date
147  if (line[41] != ' ') return false;
148  if (line[45] != ' ') return false; // space between day-of-week and month
149  if (line[49] != ' ') return false; // space between month and day of month
150  if (line[52] != ' ') return false; // space between day of month and hour
151  if (line[55] != ':') return false; // colon between hour and minutes
152  if (line[58] != ':') return false; // colon between minutes and seconds
153  if (line[61] != ' ') return false; // space between seconds and year
154  if (line[66] != ' ') return false; // two spaces between date and filename
155  if (line[67] != ' ') return false;
156 
157  try {
158  sha1.parse40(line);
159  } catch (MSXException& /*e*/) {
160  return false;
161  }
162 
163  timeStr = line + 42; // not guaranteed to be a correct date/time
164  line[66] = '\0'; // zero-terminate timeStr, so that it can be printed
165 
166  filename = line + 68;
167  *line_end = '\0'; // ok because there is certainly a '\n' after this line
168  return true;
169 }
170 
171 void FilePool::readSha1sums()
172 {
173  assert(pool.empty());
174  assert(fileMem.empty());
175 
176  File file(FileOperations::getUserDataDir() + FILE_CACHE);
177  auto size = file.getSize();
178  fileMem.resize(size + 1);
179  file.read(fileMem.data(), size);
180  fileMem[size] = '\n'; // ensure there's always a '\n' at the end
181 
182  // Process each line.
183  // Assume lines are separated by "\n", "\r\n" or "\n\r" (but not "\r").
184  char* data = fileMem.data();
185  char* data_end = data + size + 1;
186  while (data != data_end) {
187  // memchr() seems better optimized than std::find_if()
188  char* it = static_cast<char*>(memchr(data, '\n', data_end - data));
189  if (it == nullptr) it = data_end;
190  if ((it != data) && (it[-1] == '\r')) --it;
191 
193  const char* timeStr;
194  const char* filename;
195  if (parse(data, it, sum, timeStr, filename)) {
196  pool.emplace_back(sum, timeStr, filename);
197  }
198 
199  data = std::find_if(it + 1, data_end, [](char c) {
200  return !(c == '\n' || c == '\r');
201  });
202  }
203 
204  if (!ranges::is_sorted(pool, ComparePool())) {
205  // This should _rarely_ happen. In fact it should only happen
206  // when .filecache was manually edited. Though because it's
207  // very important that pool is indeed sorted I've added this
208  // safety mechanism.
209  ranges::sort(pool, ComparePool());
210  }
211 }
212 
213 void FilePool::writeSha1sums()
214 {
215  string cacheFile = FileOperations::getUserDataDir() + FILE_CACHE;
216  ofstream file;
217  FileOperations::openofstream(file, cacheFile);
218  if (!file.is_open()) {
219  return;
220  }
221  for (auto& p : pool) {
222  file << p.sum.toString() << " ";
223  if (p.timeStr) {
224  file << p.timeStr;
225  } else {
226  assert(p.time != time_t(-1));
227  file << Date::toString(p.time);
228  }
229  file << " " << p.filename << '\n';
230  }
231 }
232 
233 static int parseTypes(Interpreter& interp, const TclObject& list)
234 {
235  int result = 0;
236  unsigned num = list.getListLength(interp);
237  for (unsigned i = 0; i < num; ++i) {
238  string_view elem = list.getListIndex(interp, i).getString();
239  if (elem == "system_rom") {
240  result |= FilePool::SYSTEM_ROM;
241  } else if (elem == "rom") {
242  result |= FilePool::ROM;
243  } else if (elem == "disk") {
244  result |= FilePool::DISK;
245  } else if (elem == "tape") {
246  result |= FilePool::TAPE;
247  } else {
248  throw CommandException("Unknown type: ", elem);
249  }
250  }
251  return result;
252 }
253 
254 void FilePool::update(const Setting& setting)
255 {
256  assert(&setting == &filePoolSetting); (void)setting;
257  getDirectories(); // check for syntax errors
258 }
259 
260 FilePool::Directories FilePool::getDirectories() const
261 {
262  Directories result;
263  auto& interp = filePoolSetting.getInterpreter();
264  const TclObject& all = filePoolSetting.getValue();
265  unsigned numLines = all.getListLength(interp);
266  for (unsigned i = 0; i < numLines; ++i) {
267  Entry entry;
268  bool hasPath = false;
269  entry.types = 0;
270  TclObject line = all.getListIndex(interp, i);
271  unsigned numItems = line.getListLength(interp);
272  if (numItems & 1) {
273  throw CommandException(
274  "Expected a list with an even number "
275  "of elements, but got ", line.getString());
276  }
277  for (unsigned j = 0; j < numItems; j += 2) {
278  string_view name = line.getListIndex(interp, j + 0).getString();
279  TclObject value = line.getListIndex(interp, j + 1);
280  if (name == "-path") {
281  entry.path = value.getString().str();
282  hasPath = true;
283  } else if (name == "-types") {
284  entry.types = parseTypes(interp, value);
285  } else {
286  throw CommandException("Unknown item: ", name);
287  }
288  }
289  if (!hasPath) {
290  throw CommandException(
291  "Missing -path item: ", line.getString());
292  }
293  if (entry.types == 0) {
294  throw CommandException(
295  "Missing -types item: ", line.getString());
296  }
297  result.push_back(entry);
298  }
299  return result;
300 }
301 
302 File FilePool::getFile(FileType fileType, const Sha1Sum& sha1sum)
303 {
304  File result = getFromPool(sha1sum);
305  if (result.is_open()) return result;
306 
307  // not found in cache, need to scan directories
308  ScanProgress progress;
309  progress.lastTime = Timer::getTime();
310  progress.amountScanned = 0;
311 
312  Directories directories;
313  try {
314  directories = getDirectories();
315  } catch (CommandException& e) {
316  reactor.getCliComm().printWarning(
317  "Error while parsing '__filepool' setting", e.getMessage());
318  }
319  for (auto& d : directories) {
320  if (d.types & fileType) {
321  string path = FileOperations::expandTilde(d.path);
322  result = scanDirectory(sha1sum, path, d.path, progress);
323  if (result.is_open()) return result;
324  }
325  }
326 
327  return result; // not found
328 }
329 
330 static void reportProgress(const string& filename, size_t percentage,
331  Reactor& reactor)
332 {
333  reactor.getCliComm().printProgress(
334  "Calculating SHA1 sum for ", filename, "... ", percentage, '%');
335  reactor.getDisplay().repaint();
336 }
337 
338 static Sha1Sum calcSha1sum(File& file, Reactor& reactor)
339 {
340  // Calculate sha1 in several steps so that we can show progress
341  // information. We take a fixed step size for an efficient calculation.
342  static const size_t STEP_SIZE = 1024 * 1024; // 1MB
343 
344  auto data = file.mmap();
345  string filename = file.getOriginalName();
346 
347  SHA1 sha1;
348  size_t size = data.size();
349  size_t done = 0;
350  size_t remaining = size;
351  auto lastShowedProgress = Timer::getTime();
352  bool everShowedProgress = false;
353 
354  // Loop over all-but-the last blocks. For small files this loop is skipped.
355  while (remaining > STEP_SIZE) {
356  sha1.update(&data[done], STEP_SIZE);
357  done += STEP_SIZE;
358  remaining -= STEP_SIZE;
359 
360  auto now = Timer::getTime();
361  if ((now - lastShowedProgress) > 1000000) {
362  reportProgress(filename, (100 * done) / size, reactor);
363  lastShowedProgress = now;
364  everShowedProgress = true;
365  }
366  }
367  // last block
368  sha1.update(&data[done], remaining);
369  if (everShowedProgress) {
370  reportProgress(filename, 100, reactor);
371  }
372  return sha1.digest();
373 }
374 
375 File FilePool::getFromPool(const Sha1Sum& sha1sum)
376 {
377  auto bound = ranges::equal_range(pool, sha1sum, ComparePool());
378  // use indices instead of iterators
379  auto i = distance(begin(pool), bound.first);
380  auto last = distance(begin(pool), bound.second);
381  while (i != last) {
382  auto it = begin(pool) + i;
383  if (it->getTime() == time_t(-1)) {
384  // Invalid time/date format. Remove from
385  // database and continue searching.
386  remove(it);
387  --last;
388  continue;
389  }
390  try {
391  File file(it->filename);
392  auto newTime = file.getModificationDate();
393  if (it->time == newTime) {
394  // When modification time is unchanged, assume
395  // sha1sum is also unchanged. So avoid
396  // expensive sha1sum calculation.
397  return file;
398  }
399  it->setTime(newTime); // update timestamp
400  needWrite = true;
401  auto newSum = calcSha1sum(file, reactor);
402  if (newSum == sha1sum) {
403  // Modification time was changed, but
404  // (recalculated) sha1sum is still the same.
405  return file;
406  }
407  // Sha1sum has changed: update sha1sum, move entry to
408  // new position new sum and continue searching.
409  if (adjust(it, newSum)) {
410  // after
411  --last; // no ++i
412  } else {
413  // before (or at)
414  ++i;
415  }
416  } catch (FileException&) {
417  // Error reading file: remove from db and continue
418  // searching.
419  remove(it);
420  --last;
421  }
422  }
423  return File(); // not found
424 }
425 
426 File FilePool::scanDirectory(
427  const Sha1Sum& sha1sum, const string& directory, const string& poolPath,
428  ScanProgress& progress)
429 {
430  ReadDir dir(directory);
431  while (dirent* d = dir.getEntry()) {
432  if (quit) {
433  // Scanning can take a long time. Allow to exit
434  // openmsx when it takes too long. Stop scanning
435  // by pretending we didn't find the file.
436  return File();
437  }
438  string file = d->d_name;
439  string path = strCat(directory, '/', file);
441  if (FileOperations::getStat(path, st)) {
442  File result;
444  result = scanFile(sha1sum, path, st, poolPath, progress);
445  } else if (FileOperations::isDirectory(st)) {
446  if ((file != ".") && (file != "..")) {
447  result = scanDirectory(sha1sum, path, poolPath, progress);
448  }
449  }
450  if (result.is_open()) return result;
451  }
452  }
453  return File(); // not found
454 }
455 
456 File FilePool::scanFile(const Sha1Sum& sha1sum, const string& filename,
457  const FileOperations::Stat& st, const string& poolPath,
458  ScanProgress& progress)
459 {
460  ++progress.amountScanned;
461  // Periodically send a progress message with the current filename
462  auto now = Timer::getTime();
463  if (now > (progress.lastTime + 250000)) { // 4Hz
464  progress.lastTime = now;
465  reactor.getCliComm().printProgress(
466  "Searching for file with sha1sum ",
467  sha1sum.toString(), "...\nIndexing filepool ", poolPath,
468  ": [", progress.amountScanned, "]: ",
469  string_view(filename).substr(poolPath.size()));
470  reactor.getDisplay().repaint();
471  }
472 
473  // Note: do NOT call 'reactor.getEventDistributor().deliverEvents()'.
474  // See comment in ReverseManager::goTo() for more details.
475 
476  auto it = findInDatabase(filename);
477  if (it == end(pool)) {
478  // not in pool
479  try {
480  File file(filename);
481  auto sum = calcSha1sum(file, reactor);
482  auto time = FileOperations::getModificationDate(st);
483  insert(sum, time, filename);
484  if (sum == sha1sum) {
485  return file;
486  }
487  } catch (FileException&) {
488  // ignore
489  }
490  } else {
491  // already in pool
492  assert(filename == it->filename);
493  assert(it->time != time_t(-1));
494  try {
495  auto time = FileOperations::getModificationDate(st);
496  if (it->time == time) {
497  // db is still up to date
498  if (it->sum == sha1sum) {
499  return File(filename);
500  }
501  } else {
502  // db outdated
503  File file(filename);
504  auto sum = calcSha1sum(file, reactor);
505  it->setTime(time);
506  adjust(it, sum);
507  if (sum == sha1sum) {
508  return file;
509  }
510  }
511  } catch (FileException&) {
512  // error reading file, remove from db
513  remove(it);
514  }
515  }
516  return File(); // not found
517 }
518 
519 FilePool::Pool::iterator FilePool::findInDatabase(const string& filename)
520 {
521  // Linear search in pool for filename.
522  // Search from back to front because often, soon after this search, we
523  // will insert/remove an element from the vector. This requires
524  // shifting all elements in the vector starting from a certain
525  // position. Starting the search from the back increases the likelihood
526  // that the to-be-shifted elements are already in the memory cache.
527  auto i = pool.size();
528  while (i) {
529  --i;
530  auto it = begin(pool) + i;
531  if (it->filename == filename) {
532  // ensure 'time' is valid
533  if (it->getTime() == time_t(-1)) {
534  // invalid time/date format, remove from db
535  // and continue searching
536  pool.erase(it);
537  continue;
538  }
539  return it;
540  }
541  }
542  return end(pool); // not found
543 }
544 
546 {
547  auto time = file.getModificationDate();
548  const auto& filename = file.getURL();
549 
550  auto it = findInDatabase(filename);
551  assert((it == end(pool)) || (it->time != time_t(-1)));
552  if ((it != end(pool)) && (it->time == time)) {
553  // in database and modification time matches,
554  // assume sha1sum also matches
555  return it->sum;
556  }
557 
558  // not in database or timestamp mismatch
559  auto sum = calcSha1sum(file, reactor);
560  if (it == end(pool)) {
561  // was not yet in database, insert new entry
562  insert(sum, time, filename);
563  } else {
564  // was already in database, but with wrong timestamp (and sha1sum)
565  it->setTime(time);
566  adjust(it, sum);
567  }
568  return sum;
569 }
570 
571 int FilePool::signalEvent(const std::shared_ptr<const Event>& event)
572 {
573  (void)event; // avoid warning for non-assert compiles
574  assert(event->getType() == OPENMSX_QUIT_EVENT);
575  quit = true;
576  return 0;
577 }
578 
579 
580 // class Sha1SumCommand
581 
583  CommandController& commandController_, FilePool& filePool_)
584  : Command(commandController_, "sha1sum")
585  , filePool(filePool_)
586 {
587 }
588 
590 {
591  checkNumArgs(tokens, 2, "filename");
592  File file(tokens[1].getString());
593  result = filePool.getSha1Sum(file).toString();
594 }
595 
596 string Sha1SumCommand::help(const vector<string>& /*tokens*/) const
597 {
598  return "Calculate sha1 value for the given file. If the file is "
599  "(g)zipped the sha1 is calculated on the unzipped version.";
600 }
601 
602 void Sha1SumCommand::tabCompletion(vector<string>& tokens) const
603 {
605 }
606 
607 } // namespace openmsx
std::string getOriginalName()
Get Original filename for this object.
Definition: File.cc:138
Contains the main loop of openMSX.
Definition: Reactor.hh:66
bool isRegularFile(const Stat &st)
void update(const uint8_t *data, size_t len)
Incrementally calculate the hash value.
Definition: utils/sha1.cc:318
string join(string_view part1, string_view part2)
Join two paths.
auto sum(InputRange &&range)
Definition: stl.hh:302
const std::string & getMessage() const &
Definition: MSXException.hh:23
void registerEventListener(EventType type, EventListener &listener, Priority priority=OTHER)
Registers a given object to receive certain events.
auto distance(octet_iterator first, octet_iterator last)
string help(const vector< string > &tokens) const override
Print help for this command.
Definition: FilePool.cc:596
File getFile(FileType fileType, const Sha1Sum &sha1sum)
Search file with the given sha1sum.
Definition: FilePool.cc:302
void unregisterEventListener(EventType type, EventListener &listener)
Unregisters a previously registered event listener.
FileContext systemFileContext()
Definition: FileContext.cc:148
void openofstream(std::ofstream &stream, const std::string &filename)
Open an ofstream in a platform-independent manner.
Definition: span.hh:34
void checkNumArgs(span< const TclObject > tokens, unsigned exactly, const char *errMessage) const
Definition: Completer.cc:180
string_view getString() const
Definition: TclObject.cc:102
unsigned getListLength(Interpreter &interp) const
Definition: TclObject.cc:116
TclObject makeTclDict(Args &&... args)
Definition: TclObject.hh:286
Sha1SumCommand(CommandController &commandController, FilePool &filePool)
Definition: FilePool.cc:582
EventDistributor & getEventDistributor()
Definition: Reactor.hh:81
auto upper_bound(ForwardRange &&range, const T &value)
Definition: ranges.hh:83
std::string getURL() const
Returns the URL of this file object.
Definition: File.cc:128
auto find_if(InputRange &&range, UnaryPredicate pred)
Definition: ranges.hh:113
static void completeFileName(std::vector< std::string > &tokens, const FileContext &context, const RANGE &extra)
Definition: Completer.hh:138
auto begin(const string_view &x)
Definition: string_view.hh:151
void attach(Observer< T > &observer)
Definition: Subject.hh:49
constexpr auto data(C &c) -> decltype(c.data())
Definition: span.hh:69
void repaint()
Redraw the display.
Definition: Display.cc:327
FileContext userFileContext(string_view savePath)
Definition: FileContext.cc:160
This class represents the result of a sha1 calculation (a 160-bit value).
Definition: sha1.hh:19
Sha1Sum digest()
Get the final hash.
Definition: utils/sha1.cc:355
bool is_sorted(ForwardRange &&range)
Definition: ranges.hh:23
auto equal_range(ForwardRange &&range, const T &value)
Definition: ranges.hh:95
const TclObject & getValue() const final override
Gets the current value of this setting as a TclObject.
Definition: Setting.hh:134
void resize(size_t size)
Grow or shrink the memory block.
Definition: MemBuffer.hh:120
bool is_open() const
Return true iff this file handle refers to an open file.
Definition: File.hh:61
const T * data() const
Returns pointer to the start of the memory buffer.
Definition: MemBuffer.hh:90
string getUserDataDir()
Get the openMSX data dir in the user&#39;s home directory.
time_t getModificationDate()
Get the date/time of last modification.
Definition: File.cc:149
void sort(RandomAccessRange &&range)
Definition: ranges.hh:35
Thanks to enen for testing this on a real cartridge:
Definition: Autofire.cc:5
TclObject getListIndex(Interpreter &interp, unsigned index) const
Definition: TclObject.cc:134
std::string toString(time_t time)
Definition: Date.cc:152
bool getStat(string_view filename_, Stat &st)
Call stat() and return the stat structure.
Helper class to perform a sha1 calculation.
Definition: sha1.hh:78
std::string toString() const
Definition: utils/sha1.cc:232
This class implements a (close approximation) of the std::string_view class.
Definition: string_view.hh:16
bool isDirectory(const Stat &st)
void addListElement(T t)
Definition: TclObject.hh:121
void parse40(const char *str)
Parse from a 40-character long buffer.
Definition: utils/sha1.cc:141
const char *const FILE_CACHE
Definition: FilePool.cc:39
std::string str() const
Definition: string_view.cc:12
FilePool(CommandController &controller, Reactor &reactor)
Definition: FilePool.cc:55
void tabCompletion(vector< string > &tokens) const override
Attempt tab completion for this command.
Definition: FilePool.cc:602
void printProgress(string_view message)
Definition: CliComm.cc:30
void execute(span< const TclObject > tokens, TclObject &result) override
Execute this command.
Definition: FilePool.cc:589
Sha1Sum getSha1Sum(File &file)
Calculate sha1sum for the given File object.
Definition: FilePool.cc:545
time_t fromString(const char *p)
Definition: Date.cc:31
string_view substr(size_type pos, size_type n=npos) const
Definition: string_view.cc:32
Simple wrapper around openmdir() / readdir() / closedir() functions.
Definition: ReadDir.hh:15
void detach(Observer< T > &observer)
Definition: Subject.hh:55
CliComm & getCliComm()
Definition: Reactor.cc:304
Interpreter & getInterpreter() const
Definition: Setting.cc:161
Display & getDisplay()
Definition: Reactor.hh:85
std::string strCat(Ts &&...ts)
Definition: strCat.hh:577
uint64_t getTime()
Get current (real) time in us.
Definition: Timer.cc:8
mat4 rotate(float angle, const vec3 &axis)
Definition: gl_transform.hh:58
span< uint8_t > mmap()
Map file in memory.
Definition: File.cc:93
struct dirent * getEntry()
Get directory entry for next file.
Definition: ReadDir.cc:17
bool empty() const
No memory allocated?
Definition: MemBuffer.hh:112
constexpr auto size(const C &c) -> decltype(c.size())
Definition: span.hh:62
string expandTilde(string_view path)
Expand the &#39;~&#39; character to the users home directory.
TclObject t
void printWarning(string_view message)
Definition: CliComm.cc:20
time_t getModificationDate(const Stat &st)
Get the date/time of last modification.
auto end(const string_view &x)
Definition: string_view.hh:152