Back to blog

A stricter chess library caught my test moving White twice

I left a chess crate that had stopped getting releases, and a licence picked the replacement. The stricter crate then caught a test that had been playing an impossible move for almost a year.

• 5 min read •
rust testing dependencies licensing

Here is part of a test from my chess app, a Rust desktop program that plays against Stockfish, the free chess engine. It checks that redo is cleared when you undo a move and then play a different one.

history.make_move(create_move(Square::E2, Square::E4));
history.make_move(create_move(Square::E7, Square::E5));

history.undo();
assert_eq!(history.move_count(), 1, "Should have 1 move");
assert!(history.can_redo(), "Should be able to redo");

history.make_move(create_move(Square::D2, Square::D4));

Black's turn, White's pawn. In chess, White moves first and then the players take turns. After e4 and e5 the test takes e5 back, so Black is to move again. Then it moves White's pawn from d2, and White has just moved twice in a row. I wrote that test in October 2025 and it sat in the suite for almost a year. On 26 September it started to panic, Rust's way of stopping dead, because I had changed the library underneath it.

The old library, the chess crate, had its last release in 2021. The whole project relied on it to work out the legal moves in a position. It also pulled in failure, an error library its maintainer had deprecated, which RustSec lists as unmaintained and as unsound. RustSec collects advisories, public notices that a crate version has a known problem. Unsound means safe code could, in some situation, break the guarantees Rust is meant to give. An old rand 0.7 came along at build time too, with its own advisory. cargo audit and cargo deny check dependencies against RustSec. They only stayed green because I had told them to ignore those advisories, with a comment saying the only fix was replacing chess.

There were two candidates, and the licence decided it. shakmaty does far more, chess notation and draw detection included, so a good part of my own code could have gone. cozy-chess is mostly a move generator. But shakmaty is GPL-3.0-or-later, and my project is MIT. The MIT licence lets anyone reuse code as long as they keep the copyright and licence notice. The GPL lets people reuse code too, but the GNU FAQ says a program that links a GPL library has to be under the GPL as a whole. So shakmaty would have made the app as a whole GPL.

The project already had a licence check. Earlier in September I had given cargo deny an allow-list of the licences already in my dependencies, and anything else fails. GPL isn't on the list, so cargo deny would have refused shakmaty anyway.

cozy-chess is MIT, with no dependencies outside its own project. Its README says it aims to be a safer alternative to chess. I picked the one that does less, which leaves me more of my own code to look after.

After the swap, the undo test panicked. A bug in the new library would be the obvious guess. But cozy-chess's play is meant to panic with "Illegal move" on any move that isn't legal. My after helper calls it, and the history's make_move goes through after:

/// The board after `mv`, which must be legal.
pub fn after(board: &Board, mv: Move) -> Board {
    let mut next = board.clone();
    next.play(mv);
    next
}

So why had the test ever passed? The old crate's make_move_new doesn't check whether a move is legal. For that it has a separate legal() check that its docs call very slow, and my make_move never called it. The test never looked at the board either. It checked counts and whether undo and redo were possible, and an impossible game passes those as well as a real one. From the old crate's source, I think it coloured the moved pawn by whose turn it was, leaving a black pawn on d4. I haven't run it to check.

The awkward part is that I'd been warned. An audit of the code in February listed that make_move accepts moves without checking them. The September audit listed it again, along with a different bad test, a knight going from b8 to c7, which isn't a knight move. On 9 September I changed that one to c6. I fixed the one example in front of me and missed the next one along.

As far as I can tell, the app itself never hit this. Every move the app plays, yours or the engine's, is matched against the list of legal moves before it reaches make_move. Only tests build moves by hand without checking them.

The fix was one line. d2 to d4 became c7 to c5, a real move for Black:

-        history.make_move(create_move(Square::D2, Square::D4));
+        history.make_move(create_move(Square::C7, Square::C5));

The redo logic was never broken, only the test's input. make_move now has a doc comment that says what was always true, "Play a legal move, dropping any undone moves."

If you're about to swap a library and an old test breaks, read what the test feeds in before you blame the new one, and read what the old docs say a function doesn't check. A test that builds its input by hand can be wrong in ways its assertions never see. I'd make it go through the real legal-move path, or check the board as well as the counts.

Put the licence in the comparison from the start as well. Written down as an allow-list in deny.toml, the rule fails a check instead of relying on someone's memory.

There is still no test in the project that hands make_move an illegal move and expects it to panic.


Sources and further reading: