//! Migration tests, run against a fixture made by the Koh 2 binary itself. //! //! The fixture is why these are worth having: it is not a guess at Koh 2's //! format, it is the format, including the inlined small files and the object //! store. When `koh` (Koh 2) is not on the machine these tests skip rather //! than pretend. module migrate import json2 import os import src.events import src.history import src.snapshot import src.storage struct Fixture { dir string first string second string message string } // koh2_binary finds Koh 2: `koh2` since the cutover, `koh` before it, and never Koh 3. // Global rules must not make source fixtures depend on the developer’s home. fn testsuite_begin() { os.setenv('HOME', os.join_path(os.temp_dir(), 'koh3-test-home-${os.getpid()}'), true) } fn quiet(stage string, done int, total int) {} fn koh2_binary() ?string { for name in ['koh2', 'koh'] { path := os.find_abs_path_of_executable(name) or { continue } if os.execute('${os.quoted_path(path)} --version').output.starts_with('koh v2') { return path } } return none } fn sh(dir string, command string) !string { result := os.execute('cd ${dir} && ${command}') if result.exit_code != 0 { return error('${command} failed: ${result.output}') } return result.output } fn make_fixture(koh2 string) !Fixture { dir := os.join_path(os.temp_dir(), 'koh3_migrate_${os.getpid()}') os.rmdir_all(dir) or {} os.mkdir_all(dir, os.MkdirParams{})! sh(dir, '${koh2} init')! // One file big enough for the object store, one small enough to be inlined. os.write_file(os.join_path(dir, 'big.txt'), 'x'.repeat(700))! os.write_file(os.join_path(dir, 'small.txt'), 'tiny\n')! sh(dir, '${koh2} save "first save"')! sh(dir, '${koh2} checkpoint "before something"')! os.write_file(os.join_path(dir, 'big.txt'), 'y'.repeat(700))! sh(dir, '${koh2} save "second save"')! // Identify the first save by its message: koh2 timestamps are whole seconds, // so saves made in a row share one, and a filename is a hash. The head comes // from Koh 2's own state for the same reason. mut first := '' for name in os.ls(os.join_path(dir, '.koh', 'snapshots'))! { if !name.ends_with('.json') { continue } record := json2.decode[Manifest](os.read_file(os.join_path(dir, '.koh', 'snapshots', name))!)! if record.message == 'first save' { first = record.id } } assert first != '' return Fixture{ dir: dir first: first second: koh2_head(dir) message: 'first save' } } // koh2_head reads the lane head Koh 2 recorded. fn koh2_head(dir string) string { db := storage.connect(os.join_path(dir, '.koh', 'koh.db'), 1000) or { return '' } defer { db.close() } mut stmt := db.prepare('SELECT value FROM state WHERE key = ?') or { return '' } defer { stmt.finalize() } stmt.bind_text(1, 'head_main') or { return '' } if stmt.step() or { return '' } { return stmt.text(0) } return '' } fn test_migrating_a_koh2_repo_keeps_every_save_exactly() { koh2 := koh2_binary() or { return } fixture := make_fixture(koh2)! defer { os.rmdir_all(fixture.dir) or {} } // Flag the first save, so a save-level flag has to become an event. sh(fixture.dir, '${koh2} flag ${fixture.first} --reason "sketchy"')! // What the manifests say, read independently of the migration. mut manifests := map[string][]string{} for name in os.ls(os.join_path(fixture.dir, '.koh', 'snapshots'))! { if !name.ends_with('.json') { continue } record := json2.decode[Manifest](os.read_file(os.join_path(fixture.dir, '.koh', 'snapshots', name))!)! mut files := []string{} for file in record.files { files << '${file.path} ${bare_hash(file.hash)}' } manifests[record.id] = files } report := run(prepare(fixture.dir, default_lane, [])!, quiet)! // The fixture lives on main, because that is the lane koh2 starts on. assert report.lane == 'main' assert report.saves == 3 assert report.checkpoints == 1 assert report.flags == 1 assert report.objects >= 3 // The swap happened: `.koh` is a file now and the old data is archived. assert os.is_file(os.join_path(fixture.dir, '.koh')) assert os.is_dir(os.join_path(fixture.dir, '.koh-v2')) assert os.is_file(os.join_path(fixture.dir, '.kohignore')) store := storage.open(fixture.dir)! defer { store.close() } // Objects are counted once each, not once per save that lists them. assert report.objects == int(store.object_count()!) // Every save kept its ID, its files and their hashes. assert history.save_count(store)! == 3 for id, expected in manifests { save := history.by_id(store, id) or { assert false, 'save ${id} did not survive the migration' return } assert save.machine == 'koh2' mut actual := []string{} for file in history.files_of(store, id)! { actual << '${file.path} ${file.hash}' // Every object is present and hashes back to its key. if _ := store.get_object(file.hash) { } else { assert false, 'object ${file.hash} is missing or corrupt' } } assert actual == expected } // The checkpoint mark and the parent chain came across. middle := history.by_id(store, fixture.first) or { panic('the first save did not survive') } assert (history.head(store) or { panic('no head after migrating') }).id == fixture.second assert middle.message == 'first save' // The flag became an event, and the working tree is exactly what Koh 3 // expects once the carried ignore file is saved. assert events.flag_state(store, fixture.first)!.flagged assert events.flag_state(store, fixture.first)!.reason == 'sketchy' entries := snapshot.walk(store, fixture.dir, snapshot.load(fixture.dir))! mut changed := []string{} head_files := snapshot.index_files(history.files_of(store, fixture.second)!) for entry in entries { if entry.path !in head_files { changed << entry.path } else if head_files[entry.path].hash != entry.hash { changed << entry.path } } // The only difference is the ignore file Koh 3 just carried to the root. assert changed == ['.kohignore'] } // §13: the migration renames `kepr.build` to `koh.build`, and records the // rename as a save, so the tree it leaves behind holds no unreviewed work. fn test_migration_renames_the_koh_2_build_file() { koh2 := koh2_binary() or { return } fixture := make_fixture(koh2)! defer { os.rmdir_all(fixture.dir) or {} } build_file := 'command = true\noutput = out/demo\nplatforms = macos-aarch64\n' os.write_file(os.join_path(fixture.dir, 'kepr.build'), build_file)! sh(fixture.dir, '${koh2} save "add the build file"')! report := run(prepare(fixture.dir, default_lane, [])!, quiet)! assert report.build_renamed assert !os.exists(os.join_path(fixture.dir, 'kepr.build')) assert os.read_file(os.join_path(fixture.dir, 'koh.build'))! == build_file store := storage.open(fixture.dir)! defer { store.close() } head := history.head(store) or { panic('no head after migrating') } assert head.message == 'migrate: rename kepr.build to koh.build' files := snapshot.walk(store, fixture.dir, snapshot.load(fixture.dir))! assert snapshot.same_tree(history.files_of(store, head.id)!, files) } // A repo holding both build files is refused in the check phase, while the // Koh 2 data is still where it was. fn test_migration_refuses_two_build_files_without_moving_anything() { koh2 := koh2_binary() or { return } fixture := make_fixture(koh2)! defer { os.rmdir_all(fixture.dir) or {} } os.write_file(os.join_path(fixture.dir, 'kepr.build'), 'command = true\n')! os.write_file(os.join_path(fixture.dir, 'koh.build'), 'command = true\n')! sh(fixture.dir, '${koh2} save "both build files"')! if _ := prepare(fixture.dir, default_lane, []) { assert false, 'both build files must be refused' } else { assert err.msg().contains('both kepr.build and koh.build') } assert os.is_dir(os.join_path(fixture.dir, '.koh')) assert !os.exists(os.join_path(fixture.dir, '.koh-v2')) } // A repo with no Koh 2 build file migrates exactly as it always did. fn test_migration_without_a_build_file_is_unchanged() { koh2 := koh2_binary() or { return } fixture := make_fixture(koh2)! defer { os.rmdir_all(fixture.dir) or {} } report := run(prepare(fixture.dir, default_lane, [])!, quiet)! assert !report.build_renamed assert !os.exists(os.join_path(fixture.dir, 'koh.build')) assert os.is_dir(os.join_path(fixture.dir, '.koh-v2')) store := storage.open(fixture.dir)! defer { store.close() } head := history.head(store) or { panic('no head after migrating') } // The migration carries Koh 2's ignore rules to a root .kohignore, so the // tree differs from the imported head by exactly that file — which is what // the report tells the operator to save. saved := history.files_of(store, head.id)! mut unsaved := []string{} for file in snapshot.walk(store, fixture.dir, snapshot.load(fixture.dir))! { if !saved.any(it.path == file.path) { unsaved << file.path } } assert unsaved == [snapshot.ignore_file] } fn test_migrate_refuses_a_tree_that_does_not_match_the_last_save() { koh2 := koh2_binary() or { return } fixture := make_fixture(koh2)! defer { os.rmdir_all(fixture.dir) or {} } os.write_file(os.join_path(fixture.dir, 'big.txt'), 'unsaved change\n')! if _ := prepare(fixture.dir, default_lane, []) { assert false, 'a dirty tree must be refused' } else { assert err.msg().contains('save with koh2 first') } // Nothing was touched: still a Koh 2 repo, no archive, no draft file. assert os.is_dir(os.join_path(fixture.dir, '.koh')) assert !os.exists(os.join_path(fixture.dir, '.koh-v2')) assert !os.exists(os.join_path(fixture.dir, draft_file)) } // A damaged manifest naming a path outside the repo must never be read there. fn test_a_saved_path_is_only_read_inside_the_repo() { root := os.join_path(os.temp_dir(), 'koh3_migrate_paths_${os.getpid()}', 'repo') os.mkdir_all(root)! defer { os.rmdir_all(os.dir(root)) or {} } os.write_file(os.join_path(os.dir(root), 'outside.txt'), 'secret\n')! os.write_file(os.join_path(root, 'inside.txt'), 'fine\n')! assert disk_hash(root, 'inside.txt') != none for path in ['../outside.txt', '/etc/hosts', './inside.txt', 'a//inside.txt'] { assert disk_hash(root, path) == none, path } } fn test_disk_hash_uses_the_shared_path_rule() { root := os.join_path(os.temp_dir(), 'koh3_migrate_rule_${os.getpid()}') os.mkdir_all(root)! defer { os.rmdir_all(root) or {} } for name in ['back\\slash', 'Icon\r'] { // Joined by hand: os.join_path turns a backslash into a separator. os.write_file('${root}/${name}', 'odd\n')! assert disk_hash(root, name) == none, name } } fn test_unsafe_manifest_paths_are_dropped_and_named_once() { mut line := [ Manifest{ id: 'a' files: [ManifestFile{ path: 'ok.txt' }, ManifestFile{ path: 'Icon\r' }] }, Manifest{ id: 'b' files: [ManifestFile{ path: 'Icon\r' }, ManifestFile{ path: '../x' }] }, ] assert drop_unsafe_paths(mut line) == ['../x', 'Icon\\x0d'] assert line[0].files.map(it.path) == ['ok.txt'] assert line[1].files.len == 0 } // A version Koh 2 lost is named with the saves it was in; those saves come over // without it, each flagged with the reason, rather than the repo staying stuck. fn test_a_version_koh2_lost_is_named_and_its_saves_flagged() { koh2 := koh2_binary() or { return } fixture := make_fixture(koh2)! defer { os.rmdir_all(fixture.dir) or {} } hash := storage.hash_hex('x'.repeat(700).bytes()) os.rm(os.join_path(fixture.dir, '.koh', 'objects', hash[..2], hash[2..]))! plan := prepare(fixture.dir, default_lane, [])! assert plan.lost.map(it.path) == ['big.txt'] assert plan.lost[0].saves.len == 2 run(plan, quiet)! store := storage.open(fixture.dir)! defer { store.close() } flags := events.latest_flags(store)! assert flags[fixture.first].reason.contains('Koh 2 had lost big.txt'), flags.str() assert 'big.txt' !in history.files_of(store, fixture.first)!.map(it.path) } // Handed back with --with, found anywhere, the lost version comes home. fn test_a_lost_version_handed_back_is_imported() { koh2 := koh2_binary() or { return } fixture := make_fixture(koh2)! defer { os.rmdir_all(fixture.dir) or {} } hash := storage.hash_hex('x'.repeat(700).bytes()) os.rm(os.join_path(fixture.dir, '.koh', 'objects', hash[..2], hash[2..]))! backup := os.join_path(os.temp_dir(), 'koh3_backup_${os.getpid()}.txt') os.write_file(backup, 'x'.repeat(700))! defer { os.rm(backup) or {} } plan := prepare(fixture.dir, default_lane, [backup])! assert plan.lost.len == 0 run(plan, quiet)! store := storage.open(fixture.dir)! defer { store.close() } assert 'big.txt' in history.files_of(store, fixture.first)!.map(it.path) assert store.get_object(hash)! == 'x'.repeat(700).bytes() } // A migrate that fails clears its own draft, so the next attempt can start. fn test_a_failed_migrate_leaves_no_draft() { koh2 := koh2_binary() or { return } fixture := make_fixture(koh2)! defer { os.rmdir_all(fixture.dir) or {} } hash := storage.hash_hex('y'.repeat(700).bytes()) os.write_file(os.join_path(fixture.dir, '.koh', 'objects', hash[..2], hash[2..]), 'damaged')! if _ := run(prepare(fixture.dir, default_lane, [])!, quiet) { assert false, 'a damaged object migrated' } assert !os.exists(os.join_path(fixture.dir, draft_file)) assert os.is_dir(os.join_path(fixture.dir, '.koh')) } // Koh 2 did not always write a manifest's files in path order (nina has one such save). fn test_a_manifest_listing_its_files_out_of_order_migrates() { koh2 := koh2_binary() or { return } fixture := make_fixture(koh2)! defer { os.rmdir_all(fixture.dir) or {} } path := os.join_path(fixture.dir, '.koh', 'snapshots', '${fixture.first}.json') mut raw := json2.decode[json2.Any](os.read_file(path)!)!.as_map() mut files := raw['files']!.as_array() files.reverse_in_place() raw['files'] = json2.Any(files) os.write_file(path, json2.encode(raw))! run(prepare(fixture.dir, default_lane, [])!, quiet)! store := storage.open(fixture.dir)! defer { store.close() } assert history.files_of(store, fixture.first)!.map(it.path) == ['big.txt', 'small.txt'] }