Repository navigation
Expand file tree
/
Copy pathtest_files.cpp
More file actions
8499 lines (7302 loc) · 350 KB
/
Copy pathtest_files.cpp
File metadata and controls
8499 lines (7302 loc) · 350 KB
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
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
// This file is part of the Diffractor photo and video organizer
// Copyright 2026 Zac Walker
//
// This program is free software; you can redistribute it and / or modify it
// under the terms of the LGPL License either version 2.1 or later.
// License details are available at https://www.gnu.org/licenses/lgpl-2.1.html
// This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY
// Purpose: Tests for the file layer (files*) -- format detection, codec decode and encode, container listing, the art and media managers' .nfo files kept beside media, and the write path: staging, replacement, originals, collisions, flush failure and temp cleanup.
#include "pch.h"
#include "files.h"
#include "crypto.h"
#include "libfiles/commodore.h"
#include "libfiles/exif.h"
#include "libfiles/exif_listing.h"
#include "libfiles/media.h"
#include "libfiles/scan.h"
#include "test.h"
#include "util_zip.h"
#include "av_format.h"
#include "metadata.h"
#include "test_fixtures.h"
#include "test_runner.h"
#include "app_util.h"
#include "webp/decode.h"
#include "webp/encode.h"
#include "webp/mux.h"
#include <zlib.h>
#include <png.h>
#include <cstdio>
#include <jxl/encode.h>
#include <jxl/color_encoding.h>
#define LIBARCHIVE_STATIC
#include <archive.h>
#include <archive_entry.h>
#undef min
#undef max
#undef GetObject
extern "C" {
#include <jpeglib.h>
}
#define LIBHEIF_STATIC_BUILD 1
#include <libheif/heif.h>
#include <libheif/heif_items.h>
#include <libheif/heif_properties.h>
static void should_check_overwrite()
{
const auto src_path = core::file_path(test_files_folder(), "Test.jpg");
const auto dest_folder = test_files_folder();
df::item_set items;
const auto item = std::make_shared<df::item_element>(src_path, df::index_file_item{});
items.add(item);
const auto overwrites = check_overwrite(dest_folder, items, {});
assert_equal(true, !overwrites.empty(), "should detect existing file");
const auto no_overwrites = check_overwrite(dest_folder, items, ".xyz");
assert_equal(true, no_overwrites.empty(), "should not detect with different extension");
}
static void should_report_zip_create_failure()
{
df::zip_file zip;
const auto missing_folder = _temps.folder().combine("missing");
const auto path = missing_folder.combine_file("items.zip");
assert_equal(false, zip.create(path), "zip create failure reported");
assert_equal(false, platform::exists(path), "failed zip was not created");
}
class recording_file final : public platform::file
{
core::file_path _path;
core::blob _prefix;
uint64_t _size = 0;
mutable uint64_t _pos = 0;
public:
mutable uint64_t max_read = 0;
mutable uint64_t read_calls = 0;
recording_file(core::file_path path, core::blob prefix, const uint64_t size) :
_path(std::move(path)), _prefix(std::move(prefix)), _size(size)
{
}
uint64_t size() const override { return _size; }
uint64_t read(uint8_t* buf, const uint64_t buf_size) const override
{
const auto available = _pos < _size ? std::min(buf_size, _size - _pos) : 0;
memset(buf, 0, static_cast<size_t>(available));
if (_pos < _prefix.size())
{
const auto copied = std::min<uint64_t>(available, _prefix.size() - _pos);
memcpy(buf, _prefix.data() + static_cast<size_t>(_pos), static_cast<size_t>(copied));
}
_pos += available;
max_read = std::max(max_read, available);
++read_calls;
return available;
}
uint64_t write(const uint8_t*, uint64_t) override { return 0; }
bool flush() const override { return true; }
uint64_t seek(const uint64_t pos, const whence w) const override
{
switch (w)
{
case whence::begin:
_pos = pos;
break;
case whence::current:
_pos += pos;
break;
case whence::end:
_pos = _size + pos;
break;
}
return _pos;
}
uint64_t pos() const override { return _pos; }
bool trunc(uint64_t) const override { return false; }
core::file_path path() const override { return _path; }
};
// The copy loop stops when read64k answers false, which it does for the end of the file and for a
// read that failed alike - so a truncated copy used to close as a successful entry. What is written
// is now held to what the file holds. A real short read cannot be staged here without a fault
// injection seam, so what this pins is the other half: that a file crossing several buffer loads
// still satisfies the check rather than being refused as short.
static void should_add_a_multi_chunk_file_to_a_zip()
{
const auto source = _temps.next_path(".bin");
// Three buffer loads and a remainder, so the loop runs more than once and ends part way.
core::blob payload(64u * 1024u * 3u + 517u);
for (auto i = 0_z; i < payload.size(); ++i) payload[i] = static_cast<uint8_t>(i * 7 + (i >> 11));
platform::blob_save_to_file(payload, source);
const auto archive = _temps.next_path(".zip");
df::zip_file zip;
assert_equal(true, zip.create(archive), "the archive is created");
assert_equal(true, zip.add(source, "payload.bin"), "a file spanning several reads is added whole");
assert_equal(true, zip.close(), "and the archive closes");
const auto listed = df::zip_file::list(archive);
assert_equal(1_z, listed.size(), "the archive holds the one entry");
assert_equal(static_cast<uint64_t>(payload.size()), listed.front().uncompressed_size.to_int64(),
"and it holds every byte of the source");
}
static void should_round_trip_zip_entry_names()
{
const auto source = _temps.next_path(".bin");
const core::blob payload = {1, 2, 3};
platform::blob_save_to_file(payload, source);
const auto check_name = [&](const std::string& name)
{
const auto archive = _temps.next_path(".zip");
df::zip_file zip;
assert_equal(true, zip.create(archive), "the archive is created");
assert_equal(true, zip.add(source, name, core::date_t(2024, 1, 2, 3, 4, 6)), "the long-name entry is added");
assert_equal(true, zip.close(), "the archive closes");
const auto listed = df::zip_file::list(archive);
assert_equal(1_z, listed.size(), "the archive holds one long-name entry");
assert_equal(name.size(), listed.front().filename.size(), "the complete entry name length is listed");
assert_equal(true, listed.front().filename == name, "the complete entry name is listed exactly");
};
check_name(std::string(255, 'a'));
check_name(std::string(256, 'b'));
check_name(std::string(257, 'c'));
check_name(std::string(UINT16_MAX, 'd'));
df::zip_file zip;
assert_equal(true, zip.create(_temps.next_path(".zip")), "the archive for an oversized name is created");
assert_equal(false, zip.add(source, std::string(static_cast<size_t>(UINT16_MAX) + 1u, 'e'),
core::date_t(2024, 1, 2, 3, 4, 6)),
"an entry name beyond the ZIP 16-bit length is refused");
}
// ZIP64 headers are reserved for entries that need them: a small archive keeps the classic headers
// every reader accepts, while an entry past 4 GiB of archive, or holding nearly that much, still fits.
static void should_write_utf8_zip_entries()
{
const auto source = _temps.next_path(".bin");
write_test_file(source, "archive content");
const auto path = _temps.next_path(".zip");
const std::string name = "\xe6\x9d\xb1\xe4\xba\xac.bin";
df::zip_file zip;
assert_equal(true, zip.create(path), "ZIP created");
assert_equal(true, zip.add(source, name), "Unicode entry written");
assert_equal(true, zip.close(), "ZIP closed");
const auto bytes = platform::blob_from_file(path);
assert_equal(true, bytes.size() >= 30, "local ZIP header present");
const auto flags = bytes[6] | bytes[7] << 8;
assert_equal(true, (flags & (1 << 11)) != 0, "entry declares UTF-8 rather than relying on the reader's code page");
const auto version = bytes[4] | bytes[5] << 8;
assert_equal(true, version < 45, "a small entry keeps classic headers");
const auto listed = files::list_archive(path);
assert_equal(1_z, listed.size(), "independent libarchive reader finds the entry");
assert_equal(name, listed.front().filename, "independent reader preserves its Unicode name");
constexpr uint64_t last_classic_offset = 0xFFFFFFFEull;
constexpr uint64_t large_size = 0xFFFFFFFFull - 2ull * 1024 * 1024;
assert_equal(false, df::zip_entry_needs_zip64(last_classic_offset, 15), "the last 32-bit offset is classic");
assert_equal(true, df::zip_entry_needs_zip64(last_classic_offset + 1, 15), "an entry beyond it needs ZIP64");
assert_equal(false, df::zip_entry_needs_zip64(0, large_size - 1), "a size with deflate headroom is classic");
assert_equal(true, df::zip_entry_needs_zip64(0, large_size), "a size that could overflow needs ZIP64");
}
static void should_keep_archive_entries_after_warnings()
{
const auto source = _temps.next_path(".bin");
write_test_file(source, "archive content");
const auto path = _temps.next_path(".zip");
df::zip_file zip;
assert_equal(true, zip.create(path), "warning fixture created");
assert_equal(true, zip.add(source, "first.bin"), "first entry written");
assert_equal(true, zip.add(source, "second.bin"), "second entry written");
assert_equal(true, zip.close(), "warning fixture closed");
auto bytes = platform::blob_from_file(path);
assert_equal(true, bytes.size() > 18, "local header contains CRC");
bytes[14] = 1;
bytes[15] = bytes[16] = bytes[17] = 0;
assert_equal(true, platform::blob_save_to_file(bytes, path), "mismatched local CRC stored");
const std::unique_ptr<archive, decltype(&archive_read_free)> reader(archive_read_new(), archive_read_free);
assert_equal(true, reader != nullptr, "archive reader allocated");
archive_read_support_format_zip(reader.get());
assert_equal(ARCHIVE_OK, archive_read_open_memory(reader.get(), bytes.data(), bytes.size()), "warning fixture opened");
archive_entry* entry = nullptr;
assert_equal(ARCHIVE_WARN, archive_read_next_header(reader.get(), &entry), "fixture produces a recoverable header warning");
const auto listed = files::list_archive(path);
assert_equal(2_z, listed.size(), "a usable warned entry and its successor both remain visible");
assert_equal("second.bin", listed.back().filename, "listing continued beyond the warning");
}
static void should_convert_archive_unix_timestamps()
{
std::array<uint8_t, 2560> bytes{};
const auto header = [&bytes](const size_t offset, const std::string_view name, const char type, const unsigned long size)
{
auto* data = reinterpret_cast<char*>(bytes.data() + offset);
std::copy(name.begin(), name.end(), data);
std::memcpy(data + 100, "0000644", 7);
assert_equal(11, std::snprintf(data + 124, 12, "%011lo", size), "TAR size field fits");
assert_equal(11, std::snprintf(data + 136, 12, "%011lo", 1704164645ul), "TAR time field fits");
std::memset(data + 148, ' ', 8);
data[156] = type;
std::memcpy(data + 257, "ustar", 5);
std::memcpy(data + 263, "00", 2);
const auto checksum = std::accumulate(bytes.begin() + offset, bytes.begin() + offset + 512, 0u);
assert_equal(6, std::snprintf(data + 148, 7, "%06o", checksum), "TAR checksum field fits");
data[155] = ' ';
};
constexpr std::string_view pax = "28 ctime=1704164645.1234567\n";
static_assert(pax.size() == 28);
header(0, "PaxHeader", 'x', static_cast<unsigned long>(pax.size()));
std::copy(pax.begin(), pax.end(), bytes.begin() + 512);
header(1024, "dated.bin", '0', 0);
const auto path = _temps.next_path(".tar");
assert_equal(true, platform::blob_save_to_file({bytes.data(), bytes.size()}, path), "PAX fixture stored");
const auto listed = files::list_archive(path);
assert_equal(1_z, listed.size(), "dated archive entry found");
assert_equal(core::date_t(2024, 1, 2, 3, 4, 5).to_int64() + 1234567,
listed.front().created.to_int64(), "Unix epoch, seconds and fractional precision are converted");
}
static void should_round_trip_zip_entry_dates()
{
const auto source = _temps.next_path(".bin");
const core::blob payload = {1, 2, 3};
platform::blob_save_to_file(payload, source);
const std::array dates = {
core::date_t(2024, 1, 31, 23, 58, 56),
core::date_t(2024, 12, 31, 1, 2, 4),
core::date_t(2024, 2, 29, 6, 8, 10),
core::date_t(1975, 6, 15, 12, 0, 0),
core::date_t(1601, 1, 1, 0, 0, 0),
};
const std::array expected = {
core::date_t(2024, 1, 31, 23, 58, 56),
core::date_t(2024, 12, 31, 1, 2, 4),
core::date_t(2024, 2, 29, 6, 8, 10),
platform::local_to_utc(core::date_t(1980, 1, 1, 0, 0, 0)),
platform::local_to_utc(core::date_t(1980, 1, 1, 0, 0, 0)),
};
const auto archive = _temps.next_path(".zip");
df::zip_file zip;
assert_equal(true, zip.create(archive), "the date archive is created");
for (auto i = 0_z; i < dates.size(); ++i)
{
assert_equal(true, zip.add(source, std::format("dated-{}.bin", i), dates[i]), "the dated entry is added");
}
assert_equal(true, zip.close(), "the date archive closes");
const auto listed = df::zip_file::list(archive);
assert_equal(dates.size(), listed.size(), "every dated entry is present");
for (auto i = 0_z; i < dates.size(); ++i)
{
assert_equal(expected[i].date().year, listed[i].created.date().year, "zip date year");
assert_equal(expected[i].date().month, listed[i].created.date().month, "zip date month");
assert_equal(expected[i].date().day, listed[i].created.date().day, "zip date day");
assert_equal(expected[i].date().hour, listed[i].created.date().hour, "zip date hour");
assert_equal(expected[i].date().minute, listed[i].created.date().minute, "zip date minute");
assert_equal(expected[i].date().second, listed[i].created.date().second, "zip date second");
}
}
static void should_create_original_before_replace()
{
const auto destination = _temps.next_path(".jpg");
const auto replacement = _temps.next_path(".jpg");
const core::blob original = {1, 2, 3};
const core::blob updated = {4, 5, 6};
platform::blob_save_to_file(original, destination);
platform::blob_save_to_file(updated, replacement);
const auto result = platform::replace_file(destination, replacement, true);
const auto original_path = core::file_path(destination.folder(),
std::string(destination.file_name_without_extension()) + ".original",
destination.extension());
assert_equal(true, result.success(), "replacement with backup succeeds");
assert_equal(true, platform::exists(original_path), "original backup exists");
assert_equal(true, platform::blob_from_file(original_path) == original, "backup contains original bytes");
assert_equal(true, result.coherent_handle != nullptr, "replacement returns coherent handle");
core::blob actual(updated.size());
result.coherent_handle->seek(0, platform::file::whence::begin);
actual.resize(static_cast<size_t>(result.coherent_handle->read(actual.data(), actual.size())));
assert_equal(true, actual == updated, "destination contains updated bytes");
platform::delete_file(original_path);
}
static void should_report_move_or_copy_collision_paths()
{
const auto root = _temps.folder().combine(std::format("move-copy-{}", core::tick_count()));
const auto source = root.combine("source");
const auto target = root.combine("target");
const auto source_folder = source.combine("album");
const auto occupied_folder = target.combine("album");
platform::create_folder(source_folder);
platform::create_folder(occupied_folder);
const auto source_file = source.combine_file("photo.txt");
const auto occupied_file = target.combine_file("photo.txt");
const core::blob contents = {1};
platform::blob_save_to_file(contents, source_file);
platform::blob_save_to_file(contents, occupied_file);
platform::blob_save_to_file(contents, source_folder.combine_file("inside.txt"));
const auto result = platform::move_or_copy({source_file}, {source_folder}, target, false);
assert_equal(true, result.success(), "copy with collisions succeeds");
assert_equal(uint64_t{1}, static_cast<uint64_t>(result.created_files.files.size()), "one created file reported");
assert_equal(uint64_t{1}, static_cast<uint64_t>(result.created_files.folders.size()), "one created folder reported");
assert_equal(true, result.created_files.files.front() != occupied_file, "renamed file path reported");
assert_equal(true, result.created_files.folders.front() != occupied_folder, "renamed folder path reported");
assert_equal(true, platform::exists(result.created_files.files.front()), "reported file exists");
assert_equal(true, platform::exists(result.created_files.folders.front()), "reported folder exists");
platform::delete_items({}, {root}, false);
}
#ifndef _WIN32
static void should_report_linux_file_operation_errors()
{
const platform::file_op_result collision{platform::file_op_result_code::ALREADY_EXISTS};
const platform::file_op_result failure{platform::file_op_result_code::FAILED};
assert_equal(true, collision.failed(), "an existing destination is a failure");
assert_equal(false, collision.format_error().empty(), "a collision cannot look like an empty success message");
assert_equal(false, failure.format_error().empty(), "an unspecified failure still has a diagnostic");
}
static void should_reject_linux_folder_file_collisions()
{
const auto root = _temps.next_folder("linux-folder-collision");
const auto file = root.combine_file("occupied");
write_test_file(file, "not a directory");
assert_equal(true, platform::create_folder(root.combine("occupied")).failed(),
"an existing file is not a successful mkdir");
assert_equal("not a directory", read_test_file(file), "the colliding file is unchanged");
const auto link = root.combine("dangling");
std::filesystem::create_directory_symlink(platform::to_stream_path(root.combine("missing")),
platform::to_stream_path(link));
assert_equal(true, platform::create_folder(link).failed(), "a dangling symlink is not a directory");
assert_equal(true, platform::create_folder(root.combine("directory")).success(), "a new directory is created");
assert_equal(true, platform::create_folder(root.combine("directory")).success(), "an existing directory is accepted");
}
static void should_preserve_linux_directory_move_on_unsupported_no_replace()
{
const auto root = _temps.next_folder("linux-directory-move");
const auto source = root.combine("source");
const auto destination = root.combine("destination");
assert_equal(true, platform::create_folder(source).success(), "source directory exists");
write_test_file(source.combine_file("original.txt"), "original");
platform::test_linux_move_file_failures({true, false});
const core::scope_exit restore([] { platform::test_linux_clear_move_file_failures(); });
const auto result = platform::move_file(source, destination);
assert_equal(true, result.failed(), "unsupported atomic directory rename is a reported failure");
assert_equal("original", read_test_file(source.combine_file("original.txt")), "source is retained");
assert_equal(false, platform::exists(destination), "an unsafe fallback did not publish a destination");
}
static void should_refuse_linux_copy_identity_overwrites()
{
const auto root = _temps.next_folder("linux-copy-identity");
const auto source = root.combine_file("source.bin");
const auto alias = root.combine_file("alias.bin");
const core::blob bytes = {1, 2, 3, 4};
platform::blob_save_to_file(bytes, source);
std::filesystem::create_hard_link(platform::to_stream_path(source), platform::to_stream_path(alias));
const auto same_path = platform::copy_file(source, source, false, false);
assert_equal(true, same_path.failed(), "copy to self fails");
assert_equal(true, platform::blob_from_file(source) == bytes, "copy to self preserves source");
const auto hard_link = platform::copy_file(source, alias, false, false);
assert_equal(true, hard_link.failed(), "copy to hard-link alias fails");
assert_equal(true, platform::blob_from_file(source) == bytes, "hard-link source bytes survive");
assert_equal(true, platform::blob_from_file(alias) == bytes, "hard-link destination bytes survive");
}
static void should_report_linux_copy_flush_failures()
{
const auto root = _temps.next_folder("linux-copy-flush");
const auto source = root.combine_file("source.bin");
const auto destination = root.combine_file("destination.bin");
const core::blob source_bytes = {9, 8, 7, 6, 5, 4};
const core::blob original_destination = {1, 2, 3};
platform::blob_save_to_file(source_bytes, source);
platform::blob_save_to_file(original_destination, destination);
platform::linux_copy_file_test_failures partial;
partial.max_write_bytes = 2;
partial.interrupt_first_write = true;
platform::test_linux_copy_file_failures(partial);
const auto partial_result = platform::copy_file(source, root.combine_file("partial.bin"), true, false);
platform::test_linux_clear_copy_file_failures();
assert_equal(true, partial_result.success(), "partial and interrupted writes complete");
assert_equal(true, platform::blob_from_file(root.combine_file("partial.bin")) == source_bytes,
"partial write copy has every byte");
platform::linux_copy_file_test_failures write_failure;
write_failure.fail_write_after_calls = 0;
platform::test_linux_copy_file_failures(write_failure);
const auto write_result = platform::copy_file(source, destination, false, false);
platform::test_linux_clear_copy_file_failures();
assert_equal(true, write_result.failed(), "write failure is reported");
assert_equal(true, platform::blob_from_file(destination) == original_destination,
"write failure preserves prior destination");
platform::linux_copy_file_test_failures close_failure;
close_failure.fail_destination_close = true;
platform::test_linux_copy_file_failures(close_failure);
const auto close_result = platform::copy_file(source, destination, false, false);
platform::test_linux_clear_copy_file_failures();
assert_equal(true, close_result.failed(), "late close failure is reported");
assert_equal(true, platform::blob_from_file(destination) == original_destination,
"late close failure preserves prior destination");
}
static void should_claim_linux_auto_rename_destinations_atomically()
{
const auto root = _temps.next_folder("linux-auto-rename");
const auto source_folder = root.combine("source");
const auto target = root.combine("target");
platform::create_folder(source_folder);
platform::create_folder(target);
const auto source = source_folder.combine_file("photo.txt");
const core::blob source_bytes = {7};
const core::blob competing_bytes = {3};
platform::blob_save_to_file(source_bytes, source);
bool competed = false;
platform::test_linux_before_claim_file_path = [&](const core::file_path candidate)
{
if (!competed)
{
competed = true;
platform::blob_save_to_file(competing_bytes, candidate);
}
};
const auto result = platform::move_or_copy({source}, {}, target, false);
platform::test_linux_before_claim_file_path = {};
assert_equal(true, result.success(), "copy retries after a raced file candidate");
assert_equal(1_z, result.created_files.files.size(), "one copied file is reported");
assert_equal(true, result.created_files.files.front() != target.combine_file("photo.txt"),
"the raced name was not reported as copied");
assert_equal(true, platform::blob_from_file(target.combine_file("photo.txt")) == competing_bytes,
"competing file bytes survive");
assert_equal(true, platform::blob_from_file(result.created_files.files.front()) == source_bytes,
"reported file has source bytes");
const auto replace_source = source_folder.combine_file("replace.txt");
const auto replace_target = target.combine_file("replace.txt");
platform::blob_save_to_file(source_bytes, replace_source);
platform::blob_save_to_file(competing_bytes, replace_target);
const auto replace = platform::move_or_copy({replace_source}, {}, target, false, true);
assert_equal(true, replace.success(), "explicit replace still succeeds");
assert_equal(true, platform::blob_from_file(replace_target) == source_bytes, "replace writes source bytes");
}
static void should_reject_linux_recursive_copy_into_descendant()
{
const auto root = _temps.next_folder("linux-descendant-copy");
const auto source = root.combine("source");
const auto child = source.combine("child");
platform::create_folder(child);
platform::blob_save_to_file(core::blob{1}, source.combine_file("photo.txt"));
const auto result = platform::move_or_copy({}, {source}, child, false);
assert_equal(true, result.failed(), "copy into descendant fails");
assert_equal(false, platform::exists(child.combine("source")), "descendant target is not created");
assert_equal(true, platform::exists(source.combine_file("photo.txt")), "source remains available");
}
static void should_delete_linux_directory_symlinks()
{
const auto root = _temps.next_folder("linux-delete-symlink");
const auto source = root.combine("source");
const auto target = root.combine("target");
const auto link = source.combine("linked");
platform::create_folder(source);
platform::create_folder(target);
platform::blob_save_to_file(core::blob{1}, source.combine_file("file.txt"));
platform::blob_save_to_file(core::blob{2}, target.combine_file("target.txt"));
std::filesystem::create_directory_symlink(platform::to_stream_path(target), platform::to_stream_path(link));
const auto result = platform::delete_items({}, {source}, false);
assert_equal(true, result.success(), std::format("delete succeeds ({})", result.format_error()));
assert_equal(false, platform::exists(source), "source folder is removed");
assert_equal(true, platform::exists(target.combine_file("target.txt")), "symlink target survives");
}
static void should_move_or_copy_linux_directory_symlinks()
{
const auto root = _temps.next_folder("linux-copy-symlink");
const auto source = root.combine("source");
const auto target = root.combine("target");
const auto link_target = root.combine("linked-target");
platform::create_folder(source);
platform::create_folder(target);
platform::create_folder(link_target);
std::filesystem::create_directory_symlink(platform::to_stream_path(link_target),
platform::to_stream_path(source.combine("linked")));
const auto result = platform::move_or_copy({}, {source}, target, false);
assert_equal(true, result.success(), std::format("copy succeeds ({})", result.format_error()));
assert_equal(1_z, result.created_files.folders.size(), "one folder reported");
const auto copied_link = result.created_files.folders.front().combine("linked");
assert_equal(true, std::filesystem::is_symlink(platform::to_stream_path(copied_link)),
"directory symlink is recreated");
assert_equal(platform::to_stream_path(link_target).string(),
std::filesystem::read_symlink(platform::to_stream_path(copied_link)).string(),
"link target spelling is preserved");
}
static void should_move_or_copy_into_its_own_folder_with_auto_rename()
{
const auto root = _temps.next_folder("linux-copy-self-parent");
const auto file = root.combine_file("photo.txt");
const auto folder = root.combine("album");
platform::blob_save_to_file(core::blob{7}, file);
platform::create_folder(folder);
platform::blob_save_to_file(core::blob{8}, folder.combine_file("inside.txt"));
const auto file_result = platform::move_or_copy({file}, {}, root, false);
assert_equal(true, file_result.success(), std::format("file copy succeeds ({})", file_result.format_error()));
assert_equal(1_z, file_result.created_files.files.size(), "one copied file reported");
assert_equal(true, file_result.created_files.files.front() != file, "copy uses a new file name");
assert_equal(true, platform::blob_from_file(file_result.created_files.files.front()) == core::blob{7},
"copied file has the source bytes");
const auto folder_result = platform::move_or_copy({}, {folder}, root, false);
assert_equal(true, folder_result.success(), std::format("folder copy succeeds ({})", folder_result.format_error()));
assert_equal(1_z, folder_result.created_files.folders.size(), "one copied folder reported");
assert_equal(true, folder_result.created_files.folders.front() != folder, "copy uses a new folder name");
assert_equal(true, platform::exists(folder_result.created_files.folders.front().combine_file("inside.txt")),
"copied folder has the source contents");
}
static void should_move_or_copy_stop_on_inner_linux_folder_collision()
{
const auto root = _temps.next_folder("linux-inner-collision");
const auto source = root.combine("album");
const auto target = root.combine("target");
platform::create_folder(source);
platform::create_folder(target);
platform::blob_save_to_file(core::blob{5}, source.combine_file("inside.txt"));
bool competed = false;
platform::test_linux_before_copy_folder_file_path = [&](const core::file_path candidate)
{
if (!competed)
{
competed = true;
platform::blob_save_to_file(core::blob{6}, candidate);
}
};
const auto result = platform::move_or_copy({}, {source}, target, false);
platform::test_linux_before_copy_folder_file_path = {};
assert_equal(true, result.failed(), "inner collision fails the claimed tree");
assert_equal(false, platform::exists(target.combine("album (2)")), "inner collision does not retry the top folder");
assert_equal(true, platform::blob_from_file(target.combine("album").combine_file("inside.txt")) == core::blob{6},
"competing inner file survives");
}
static void should_move_linux_no_replace_without_renameat_or_links()
{
const auto root = _temps.next_folder("linux-move-fallback");
const auto source = root.combine_file("source.txt");
const auto destination = root.combine_file("destination.txt");
const core::blob bytes = {4, 3, 2};
platform::blob_save_to_file(bytes, source);
platform::test_linux_move_file_failures({true, true});
const auto result = platform::move_file(source, destination, true);
platform::test_linux_clear_move_file_failures();
assert_equal(true, result.success(), std::format("fallback move succeeds ({})", result.format_error()));
assert_equal(false, platform::exists(source), "source is removed after the exclusive copy");
assert_equal(true, platform::blob_from_file(destination) == bytes, "destination has source bytes");
}
static void should_refuse_or_preserve_linux_replace_permissions()
{
const auto root = _temps.next_folder("linux-copy-permissions");
const auto source = root.combine_file("source.txt");
const auto read_only = root.combine_file("read-only.txt");
const auto private_file = root.combine_file("private.txt");
const core::blob source_bytes = {9};
const core::blob original_bytes = {1};
platform::blob_save_to_file(source_bytes, source);
platform::blob_save_to_file(original_bytes, read_only);
platform::blob_save_to_file(original_bytes, private_file);
namespace fs = std::filesystem;
fs::permissions(platform::to_stream_path(read_only),
fs::perms::owner_read | fs::perms::group_read | fs::perms::others_read,
fs::perm_options::replace);
const auto refused = platform::copy_file(source, read_only, false, false);
assert_equal(true, refused.failed(), "read-only destination refuses replace");
assert_equal(true, platform::blob_from_file(read_only) == original_bytes, "read-only destination bytes survive");
fs::permissions(platform::to_stream_path(private_file), fs::perms::owner_read | fs::perms::owner_write,
fs::perm_options::replace);
const auto replaced = platform::copy_file(source, private_file, false, false);
assert_equal(true, replaced.success(), std::format("replace succeeds ({})", replaced.format_error()));
const auto mode = fs::status(platform::to_stream_path(private_file)).permissions() & fs::perms::all;
assert_equal(true, mode == (fs::perms::owner_read | fs::perms::owner_write),
"replace preserves destination permissions");
}
static void should_move_or_copy_reject_linux_destination_alias_inside_source()
{
const auto root = _temps.next_folder("linux-copy-alias-descendant");
const auto source = root.combine("source");
const auto nested = source.combine("a").combine("b");
const auto target = root.combine("target-link");
platform::create_folder(nested);
platform::blob_save_to_file(core::blob{1}, source.combine_file("top.txt"));
platform::blob_save_to_file(core::blob{2}, source.combine("a").combine_file("mid.txt"));
std::filesystem::create_directory_symlink(platform::to_stream_path(nested), platform::to_stream_path(target));
const auto result = platform::move_or_copy({}, {source}, target, false);
assert_equal(true, result.failed(), "alias into source is rejected");
assert_equal(false, platform::exists(nested.combine("source")), "copy does not create output inside source");
assert_equal(true, platform::exists(source.combine_file("top.txt")), "source remains available");
}
#endif
static void should_fail_replace_when_flush_fails()
{
const auto result = platform::replacement_flush_result(false, "flush failed");
assert_equal(true, result.failed(), "failed flush stops replacement");
assert_equal("flush failed", result.error_message, "flush error preserved");
#ifdef _WIN32
// Holding the replacement open is what makes the flush fail, and that is Windows' mandatory
// locking. A POSIX open descriptor denies nothing, so there is no failure here to observe.
const auto destination = _temps.next_path(".bin");
const auto replacement = _temps.next_path(".bin");
const core::blob original = {1, 2, 3};
const core::blob updated = {4, 5, 6};
platform::blob_save_to_file(original, destination);
platform::blob_save_to_file(updated, replacement);
auto locked = platform::open_file(replacement, platform::file_open_mode::read_write);
assert_equal(true, locked != nullptr, "replacement locked");
const auto real_result = platform::replace_file(destination, replacement);
locked.reset();
assert_equal(true, real_result.failed(), "real flush failure stops replacement");
assert_equal(true, !real_result.error_message.empty(), "real flush error reported");
assert_equal(true, platform::blob_from_file(destination) == original, "destination unchanged after flush failure");
#endif
}
#ifdef _WIN32
// The failure this cleans up after is induced by holding the destination open, which denies the
// write only where locking is mandatory. POSIX has no equivalent, so the failure cannot be staged
// here and the cleanup it proves has no way to be observed.
static void should_cleanup_failed_update_temps()
{
const auto src_path = core::file_path(test_files_folder(), "Test.jpg");
// A private folder: enumerating the shared suite temp folder would scan every file every other
// test has left there.
const auto scratch = _temps.next_folder("update-temps");
const auto destination = _temps.next_path_in(scratch, ".jpg");
platform::copy_file(src_path, destination, false, false);
std::vector<core::cached> files_before;
for (const auto& file : platform::iterate_file_items(scratch, false).files)
{
files_before.emplace_back(file.name);
}
auto locked = platform::open_file(destination, platform::file_open_mode::read_write);
assert_equal(true, locked != nullptr, "destination locked");
metadata_edits edits;
edits.rating = 3;
files ff;
const auto result = ff.update(destination, edits, {}, {}, false, {});
locked.reset();
assert_equal(true, result.failed(), "locked update fails");
const auto contents = platform::iterate_file_items(scratch, false);
const auto leaked = std::ranges::any_of(contents.files, [&files_before](const platform::file_info& file)
{
return std::ranges::find(files_before, file.name) == files_before.end() &&
core::starts(file.name, "diffractor_");
});
assert_equal(false, leaked, "failed update removes temporary files");
}
// A read-only source written to another path was staged as a read-only copy: the write to it was
// refused, and so was the cleanup, which left a full copy behind in the destination folder.
static void should_write_a_read_only_source_to_another_path()
{
const auto scratch = _temps.next_folder("read-only-source");
const auto source = core::file_path(scratch, "source.jpg");
const auto destination = core::file_path(scratch, "copy.jpg");
platform::copy_file(core::file_path(test_files_folder(), "Test.jpg"), source, false, false);
std::filesystem::permissions(platform::to_stream_path(source),
std::filesystem::perms::owner_write | std::filesystem::perms::group_write |
std::filesystem::perms::others_write, std::filesystem::perm_options::remove);
const core::scope_exit restore([source] { platform::make_file_writable(source); });
assert_equal(true, platform::file_attributes(source).is_readonly, "the source is read-only");
metadata_edits edits;
edits.rating = 3;
files ff;
const auto result = ff.update(source, destination, edits, {}, {}, false, {});
assert_equal(true, result.success(), "a read-only source is written to another path");
const auto contents = platform::iterate_file_items(scratch, false);
const auto stage_left = std::ranges::any_of(contents.files, [](const platform::file_info& file)
{
return core::starts(file.name, "diffractor_");
});
assert_equal(false, stage_left, "and no staged copy is left behind");
assert_equal(true, platform::exists(destination), "the copy is written");
}
#endif
static void should_settle_transport_stream_extension_by_header()
{
assert_equal(true, files::has_media_header_rule(".ts"), "the transport stream extension has a header rule");
assert_equal(true, files::has_media_header_rule("m2ts"), "the rule ignores a leading dot");
assert_equal(false, files::has_media_header_rule(".mp4"), "an unambiguous extension is left to the decoder");
std::array<uint8_t, files::media_header_probe_bytes> header{};
const auto write_packets = [&header](const size_t start, const size_t packet_size)
{
header.fill(0);
for (auto i = size_t{0}; i < 4; ++i) header[start + i * packet_size] = 0x47;
};
write_packets(0, 188);
assert_equal(true, files::media_header_matches(".ts", {header.data(), header.size()}),
"a broadcast packet run is accepted");
write_packets(4, 192);
assert_equal(true, files::media_header_matches(".m2ts", {header.data(), header.size()}),
"an M2TS timestamp prefix is accepted");
write_packets(0, 204);
assert_equal(true, files::media_header_matches(".ts", {header.data(), header.size()}),
"a Reed-Solomon packet run is accepted");
// A capture that begins mid-packet still aligns further in, and ffmpeg would find it, so refusing
// it here would hide real video.
write_packets(97, 188);
assert_equal(true, files::media_header_matches(".ts", {header.data(), header.size()}),
"a stream that starts mid-packet is accepted");
// A TypeScript file that opens with 'G' (0x47) matches the sync byte but not the packet run.
const std::string_view typescript = "Get the exported type before anything else is imported;\n";
header.fill(0);
std::memcpy(header.data(), typescript.data(), typescript.size());
assert_equal(false, files::media_header_matches(".ts", {header.data(), typescript.size()}),
"TypeScript source is not mistaken for a transport stream");
assert_equal(true, files::media_header_matches(".mp4", {header.data(), typescript.size()}),
"an extension with no rule always matches");
}
static std::vector<uint8_t> make_test_xmp_packet(const std::string_view body)
{
const auto xml = std::format(
"<?xpacket begin=\"\" id=\"W5M0MpCehiHzreSzNTczkc9d\"?>"
"<x:xmpmeta xmlns:x=\"adobe:ns:meta/\"><rdf:RDF "
"xmlns:rdf=\"http://www.w3.org/1999/02/22-rdf-syntax-ns#\">"
"<rdf:Description rdf:about=\"\" "
"xmlns:dc=\"http://purl.org/dc/elements/1.1/\">{}</rdf:Description>"
"</rdf:RDF></x:xmpmeta><?xpacket end=\"w\"?>",
body);
return {std::bit_cast<const uint8_t*>(xml.data()), std::bit_cast<const uint8_t*>(xml.data() + xml.size())};
}
static core::surface_ptr make_gradient_surface(int cx, int cy);
static void should_fail_webp_save_when_metadata_chunk_insertion_fails()
{
files ff;
const auto surface = make_gradient_surface(16, 16);
file_encode_params params;
params.webp_lossless = true;
const auto assert_failed_chunk = [&](const std::string_view fourcc, const libfiles::metadata_blocks& metadata,
const core::const_surface_ptr& source)
{
files_test_hooks::fail_next_webp_chunk(fourcc);
const auto saved = save_webp(source, metadata, params);
assert_equal(false, is_valid(saved), std::format("{} insertion failure fails the save", fourcc));
};
libfiles::metadata_blocks icc;
icc.icc = {1, 2, 3, 4};
assert_failed_chunk("ICCP", icc, surface);
libfiles::metadata_blocks exif;
exif.exif = {'E', 'x', 'i', 'f', 0, 0, 'I', 'I', 0x2a, 0};
assert_failed_chunk("EXIF", exif, surface);
libfiles::metadata_blocks orientation_metadata;
const auto rotated = std::make_shared<core::surface>();
rotated->copy(*surface, surface->dimensions());
rotated->orientation(core::orientation::right_top);
assert_failed_chunk("EXIF", orientation_metadata, rotated);
libfiles::metadata_blocks xmp;
xmp.xmp = make_test_xmp_packet(
"<dc:title><rdf:Alt><rdf:li xml:lang=\"x-default\">webp</rdf:li></rdf:Alt></dc:title>");
assert_failed_chunk("XMP ", xmp, surface);
}
static void should_scan_d64()
{
constexpr auto file_name = "Ace of Aces (Europe).D64";
const auto load_path = test_files_folder().combine("retro").combine_file(file_name);
const auto loaded = platform::blob_from_file(load_path);
const auto contents = libfiles::list_c64_media({loaded.data(), loaded.size()});
assert_equal(4_z, contents.rows.size(), "d64", file_name);
assert_equal("147 \"ACE OF ACES+ \" PRG", contents.rows[0].text, "d64", file_name);
}
static void should_scan_archive()
{
constexpr auto file_name = "benchmarks.zip";
const auto load_path = test_files_folder().combine_file(file_name);
const auto contents = files::list_archive(load_path);
assert_equal(2_z, contents.size(), "archive", file_name);
assert_equal("PXL_20240404_074316577.jpg", contents[0].filename, "archive", file_name);
}
static void should_detect_tiff_by_version()
{
// The byte-order mark alone is shared with plenty of non-TIFF files, so the
// version word decides: 42 is classic TIFF and 43 is BigTIFF.
constexpr uint8_t little_endian_42[] = {'I', 'I', 42, 0, 8, 0, 0, 0};
constexpr uint8_t big_endian_42[] = {'M', 'M', 0, 42, 0, 0, 0, 8};
constexpr uint8_t big_endian_43[] = {'M', 'M', 0, 43, 0, 8, 0, 0};
constexpr uint8_t not_tiff[] = {'I', 'I', 'B', 'M', 0, 0, 0, 0};
assert_equal(true, libfiles::detect_image_format({little_endian_42, std::size(little_endian_42)}) ==
libfiles::image_format::tiff, "little endian tiff");
assert_equal(true, libfiles::detect_image_format({big_endian_42, std::size(big_endian_42)}) ==
libfiles::image_format::tiff, "big endian tiff");
assert_equal(true, libfiles::detect_image_format({big_endian_43, std::size(big_endian_43)}) ==
libfiles::image_format::tiff, "big endian bigtiff");
assert_equal(true, libfiles::detect_image_format({not_tiff, std::size(not_tiff)}) ==
libfiles::image_format::unknown, "not tiff");
}
// Each still format is one entry in files.cpp's s_still_formats. Its file type still has to take its
// place in the extension table, and libfiles has to name it from its signature - a format added to one
// and not the others is what this exists to catch.
static void should_register_every_still_format()
{
const std::map<libfiles::image_format, std::vector<uint8_t>> signatures = {
{libfiles::image_format::psd, {'8', 'B', 'P', 'S'}},
{libfiles::image_format::gif, {'G', 'I', 'F', '8', '9', 'a'}},
{libfiles::image_format::jxl, {0xFF, 0x0A, 0x00, 0x00}},
{libfiles::image_format::webp, {'R', 'I', 'F', 'F', 0, 0, 0, 0, 'W', 'E', 'B', 'P'}},
{libfiles::image_format::jpeg, {0xFF, 0xD8, 0xFF, 0xE0}},
{libfiles::image_format::bmp, {'B', 'M', 0, 0}},
{libfiles::image_format::png, {0x89, 'P', 'N', 'G'}},
{libfiles::image_format::tiff, {'I', 'I', 42, 0}},
{libfiles::image_format::heif, {0, 0, 0, 0x18, 'f', 't', 'y', 'p', 'h', 'e', 'i', 'c'}},
};
std::set<libfiles::image_format> seen;
for (const auto& still : still_formats())
{
const auto name = std::string(core::split(still.extensions, true).front());
assert_equal(true, seen.insert(still.format).second, std::format("{} has one entry", name));
const auto signature = signatures.find(still.format);
assert_equal(true, signature != signatures.end() &&
libfiles::detect_image_format({signature->second.data(), signature->second.size()}) == still.format,
std::format("{} is detected", name));
assert_equal(true, still.scan != nullptr, std::format("{} is scanned", name));
assert_equal(true, still.encoded == core::image_format::Unknown || still.decode != nullptr,
std::format("{} kept encoded can still be decoded", name));
for (const auto ext : core::split(still.extensions, true))
{
const auto* const ft = files::file_type_from_name(std::format("x.{}", ext));
assert_equal(true, ft->extension == still.extensions && ft->text == still.description &&
ft->traits == still.traits, std::format("{} registers as its still format", ext));
}
}
assert_equal(true, find_still_format(libfiles::image_format::unknown) == nullptr, "unknown is not a format");
}
static void should_scan_and_load_bitmap_psd()
{
// A minimal uncompressed 1-bit-per-pixel bitmap-mode psd. Photoshop stores
// bitmap mode inverted, so a set bit is black and a clear bit is white.
constexpr uint8_t bitmap_psd[] = {
'8', 'B', 'P', 'S', 0, 1, // signature and version
0, 0, 0, 0, 0, 0, // reserved
0, 1, // channels
0, 0, 0, 2, // rows
0, 0, 0, 16, // columns
0, 1, // depth
0, 0, // mode - bitmap
0, 0, 0, 0, // colour mode data length
0, 0, 0, 0, // image resource length
0, 0, 0, 0, // layer and mask length
0, 0, // compression - none
0b1010'1010, 0b0000'1111, // row 0
0b0000'0000, 0b1111'1111, // row 1
};
mem_read_stream scan_stream({bitmap_psd, std::size(bitmap_psd)});
const auto scanned = scan_photo(scan_stream);
assert_equal(true, scanned.success, "bitmap psd scanned");
assert_equal(16u, scanned.width, "bitmap psd width");
assert_equal(2u, scanned.height, "bitmap psd height");
assert_equal("mono"_c, scanned.pixel_format, "bitmap psd pixel format");
mem_read_stream load_stream({bitmap_psd, std::size(bitmap_psd)});
const auto surface = load_psd(load_stream);
assert_equal(true, is_valid(surface), "bitmap psd loaded");
assert_equal(16, static_cast<int>(surface->width()), "bitmap psd surface width");
assert_equal(2, static_cast<int>(surface->height()), "bitmap psd surface height");