Skip to main content

ant_core/
update.rs

1use std::path::{Path, PathBuf};
2
3use ant_protocol::pqc::api::{ml_dsa_65, MlDsaPublicKey, MlDsaSignature, MlDsaVariant};
4use futures_util::StreamExt;
5use serde::{Deserialize, Serialize};
6
7use crate::channel::version_matches_channel;
8use crate::error::{Error, Result};
9use crate::install::{self, InstallMethod};
10use crate::node::binary::{extract_tar_gz, extract_zip, ProgressReporter};
11use crate::node::types::UpgradeChannel;
12
13const GITHUB_REPO: &str = "WithAutonomi/ant-client";
14const CLI_BINARY_NAME: &str = "ant";
15const TAG_PREFIX: &str = "ant-cli-v";
16
17/// Signing context for domain separation (must match the context used by `ant-keygen sign`).
18const SIGNING_CONTEXT: &[u8] = b"ant-release-v1";
19
20/// ML-DSA-65 signature size in bytes.
21const SIGNATURE_SIZE: usize = 3309;
22
23/// Embedded release signing public key (ML-DSA-65).
24///
25/// This key is used to verify signatures on released ant-client binaries.
26/// The corresponding private key is held by authorised release signers.
27/// Generated: 2026-03-30 19:49:36 UTC
28const RELEASE_SIGNING_KEY: &[u8] = &[
29    0xb4, 0xcf, 0x2a, 0x24, 0x31, 0xd9, 0xb2, 0x3a, 0xab, 0xe9, 0x5e, 0xfc, 0xbc, 0xf3, 0xb1, 0x1f,
30    0x4e, 0x50, 0x0a, 0x46, 0xeb, 0x83, 0xfc, 0x6f, 0x0f, 0x89, 0x41, 0x00, 0x1b, 0x52, 0xde, 0xdc,
31    0xb5, 0xc9, 0x07, 0xed, 0x72, 0x3a, 0xe1, 0xa9, 0x82, 0xe1, 0xfc, 0xff, 0xab, 0xce, 0x9f, 0x7d,
32    0xab, 0xe2, 0x57, 0x92, 0xe0, 0xf2, 0xac, 0xa1, 0x41, 0xd3, 0x74, 0x95, 0x41, 0xd1, 0xac, 0x22,
33    0xb1, 0xbb, 0x5c, 0xf4, 0x02, 0x0b, 0x73, 0x85, 0xfd, 0x56, 0x75, 0x0d, 0x5c, 0x38, 0xe1, 0x2d,
34    0xe0, 0x15, 0x4f, 0xbf, 0x40, 0xeb, 0xf1, 0x0d, 0x8f, 0x39, 0x32, 0xeb, 0x80, 0xa7, 0x2e, 0x77,
35    0x3e, 0x54, 0xe3, 0x3d, 0x62, 0xae, 0xe7, 0x09, 0x5e, 0xfb, 0xdc, 0xaa, 0x07, 0xdc, 0xe1, 0x08,
36    0x96, 0xbb, 0x4b, 0xa0, 0x2e, 0x05, 0x2d, 0xee, 0xeb, 0x9a, 0x1a, 0xae, 0xde, 0xe9, 0x2c, 0xf8,
37    0x2f, 0x43, 0x6c, 0x78, 0x4b, 0xde, 0xef, 0x91, 0x8b, 0x94, 0x9d, 0x4f, 0x81, 0x05, 0xcc, 0xf0,
38    0x98, 0xce, 0xce, 0x67, 0x54, 0xac, 0xde, 0xcd, 0x26, 0x9e, 0x84, 0xf4, 0x88, 0xb2, 0x1a, 0x3e,
39    0x93, 0x2e, 0xff, 0xa8, 0x45, 0x95, 0xd1, 0xd0, 0xb1, 0x6c, 0x3c, 0x1e, 0xef, 0x3d, 0xe3, 0xf2,
40    0x73, 0xe2, 0xf6, 0xb7, 0xf9, 0x3f, 0x10, 0x0d, 0x3c, 0xde, 0x28, 0x94, 0x07, 0xef, 0x24, 0x70,
41    0xc4, 0x5a, 0x0a, 0x67, 0xbb, 0x0f, 0x4f, 0x5c, 0x2b, 0xd8, 0x02, 0x05, 0xa5, 0x98, 0x03, 0x5d,
42    0x8f, 0xc0, 0x4a, 0x84, 0xe9, 0xea, 0xac, 0x13, 0xdf, 0x69, 0xfc, 0x1e, 0xcf, 0xb6, 0x88, 0xba,
43    0x99, 0x30, 0xbc, 0x7a, 0xb8, 0x9d, 0x3d, 0x62, 0x3b, 0x33, 0x19, 0xbb, 0x3a, 0x2c, 0x2b, 0xa0,
44    0x5a, 0xb0, 0x8f, 0x9e, 0x10, 0x81, 0xb9, 0x12, 0x54, 0x81, 0xf8, 0xe2, 0x91, 0xb2, 0xe7, 0xe6,
45    0x9c, 0x11, 0xeb, 0x49, 0x64, 0x3a, 0x25, 0xd6, 0x53, 0x2e, 0xdf, 0xfc, 0x14, 0x32, 0x65, 0xcc,
46    0x87, 0xdb, 0xfd, 0xbb, 0x81, 0xa8, 0x50, 0xcc, 0xb4, 0x31, 0x0c, 0x70, 0xf3, 0xb6, 0x15, 0x8a,
47    0x50, 0x80, 0xad, 0xb1, 0xb0, 0x10, 0x2b, 0x67, 0x33, 0xf5, 0xf6, 0x36, 0x35, 0x5f, 0xa7, 0xd2,
48    0x81, 0xd6, 0x75, 0xa1, 0x18, 0x15, 0xbe, 0x1d, 0x5e, 0x33, 0x8e, 0x98, 0xdd, 0x45, 0x0f, 0x0c,
49    0x0f, 0x0d, 0x8b, 0x3f, 0x97, 0x11, 0x21, 0x2e, 0xa0, 0x5e, 0xfe, 0x70, 0x09, 0xa7, 0x14, 0x30,
50    0xa3, 0x01, 0x2d, 0x18, 0x2b, 0x8f, 0x19, 0x75, 0x54, 0x1f, 0xd8, 0xee, 0x66, 0x06, 0x7b, 0x9d,
51    0x7d, 0xb2, 0xae, 0x14, 0xe6, 0x51, 0x19, 0xc2, 0x45, 0x2e, 0x7e, 0x11, 0xd9, 0x7b, 0x16, 0x8e,
52    0xae, 0x17, 0xdb, 0x1b, 0x24, 0x90, 0xcd, 0xed, 0x94, 0xf9, 0xf7, 0xba, 0x9f, 0x4c, 0x12, 0xae,
53    0x31, 0x7b, 0xd4, 0x7c, 0x04, 0x42, 0x2c, 0x32, 0x16, 0xc1, 0x70, 0x6d, 0x11, 0x6f, 0x3b, 0x44,
54    0x62, 0xba, 0xbd, 0xc5, 0x7a, 0xec, 0x55, 0x1b, 0xcd, 0xdb, 0xb6, 0x55, 0x08, 0x86, 0x13, 0x7f,
55    0x4e, 0xa9, 0x63, 0xe1, 0x87, 0xa0, 0x7e, 0x49, 0xfb, 0xf4, 0xa3, 0x46, 0xcf, 0x1d, 0xec, 0xf5,
56    0xc6, 0x2f, 0xe1, 0x43, 0x02, 0xd0, 0xe4, 0x5f, 0x1b, 0x20, 0x1a, 0xa7, 0x81, 0xbd, 0x31, 0x19,
57    0x6a, 0x74, 0xd7, 0x9a, 0x6d, 0x3d, 0xf8, 0xac, 0x4d, 0xbb, 0x01, 0x63, 0xa4, 0x9d, 0x3c, 0xc9,
58    0x6c, 0x8a, 0x4f, 0x61, 0xd6, 0x98, 0xf5, 0x40, 0x22, 0xa9, 0x5e, 0x93, 0x5e, 0x13, 0xd3, 0xe0,
59    0xdb, 0x54, 0xab, 0x0d, 0xe3, 0x88, 0x85, 0x80, 0x7a, 0x5e, 0x38, 0x64, 0x97, 0xc4, 0xe9, 0xb0,
60    0x5d, 0xf7, 0x40, 0x5f, 0x6e, 0x3f, 0xbe, 0x14, 0x9a, 0x7c, 0xa2, 0x7c, 0x74, 0xa5, 0x32, 0x22,
61    0x61, 0x31, 0xa5, 0x0d, 0xa5, 0xcc, 0x93, 0xe4, 0xfd, 0xed, 0xbc, 0xe7, 0xf2, 0xe5, 0xdb, 0xc6,
62    0x0c, 0xe1, 0xc8, 0x4e, 0xee, 0xe6, 0x76, 0x1c, 0x10, 0x1b, 0xd8, 0x53, 0xd4, 0xe8, 0x07, 0xed,
63    0xea, 0x91, 0xd4, 0x1b, 0x91, 0x5c, 0x28, 0x05, 0xca, 0xe2, 0x9c, 0xd1, 0x99, 0x43, 0xed, 0xd8,
64    0x6a, 0x2b, 0xd2, 0x64, 0x9b, 0xe1, 0x0c, 0x88, 0x6c, 0x0d, 0xb2, 0x6b, 0x73, 0x85, 0x9d, 0xbf,
65    0x79, 0x78, 0xaa, 0x7b, 0x5e, 0xf8, 0xa4, 0x26, 0xdf, 0xb3, 0x9b, 0x24, 0x5a, 0xc8, 0x19, 0x22,
66    0xa5, 0xc6, 0xca, 0x00, 0x59, 0x3b, 0xad, 0x45, 0xdf, 0x71, 0x1d, 0x60, 0x60, 0x24, 0x0d, 0xa4,
67    0x3d, 0x42, 0x23, 0xb8, 0xfe, 0xac, 0x86, 0x94, 0x79, 0x87, 0x05, 0xae, 0xb8, 0x4d, 0x7e, 0x11,
68    0x5b, 0x22, 0x44, 0x15, 0x3d, 0x7f, 0x82, 0x98, 0x65, 0x0a, 0x3c, 0xd3, 0xef, 0x80, 0x0d, 0x75,
69    0x03, 0x92, 0xf6, 0x3a, 0x8b, 0xa4, 0xb0, 0x61, 0x2d, 0x2c, 0xcc, 0x1f, 0x01, 0x8e, 0x7a, 0x46,
70    0x36, 0x2a, 0x83, 0x21, 0x88, 0x98, 0x13, 0x0a, 0xd5, 0xa1, 0x54, 0x4b, 0x63, 0xe0, 0xe3, 0x1c,
71    0x07, 0x5e, 0x32, 0x8c, 0xa4, 0x6b, 0x62, 0xc3, 0x28, 0x95, 0xb8, 0x0a, 0xb9, 0x4f, 0xaf, 0x7f,
72    0x49, 0xeb, 0xff, 0xd7, 0xa1, 0x41, 0x43, 0x9a, 0x92, 0x9a, 0x5f, 0xee, 0xbd, 0xb9, 0xbe, 0xb3,
73    0x4b, 0x9d, 0x0b, 0xcb, 0x9b, 0x2c, 0x26, 0x8f, 0x0f, 0xb1, 0xfa, 0xc0, 0xe3, 0x3a, 0x7f, 0x2b,
74    0x51, 0x79, 0x75, 0x25, 0x5b, 0x23, 0x22, 0xb1, 0x01, 0x27, 0x4e, 0x43, 0xdd, 0x66, 0x7a, 0x33,
75    0x4d, 0x32, 0x96, 0x83, 0x59, 0x52, 0xd7, 0x3c, 0xb0, 0xe3, 0x03, 0xd6, 0xb0, 0xc7, 0x99, 0x68,
76    0xc6, 0xa4, 0x2d, 0x35, 0x3d, 0xa4, 0x6a, 0x17, 0xd9, 0xf4, 0x0c, 0x26, 0x11, 0xe4, 0xbc, 0x03,
77    0x87, 0x25, 0x62, 0xad, 0xa9, 0x7e, 0x96, 0x4d, 0x39, 0x9b, 0x8f, 0x09, 0xdc, 0xd1, 0x28, 0x5e,
78    0xf4, 0xe3, 0x94, 0xfd, 0x94, 0x46, 0x30, 0xe2, 0x24, 0x46, 0x30, 0x7f, 0xf4, 0x4c, 0xaa, 0x51,
79    0x7e, 0x04, 0x5c, 0xa4, 0x8c, 0xba, 0x4a, 0xb8, 0x61, 0x5e, 0x75, 0x1c, 0xa8, 0x0c, 0xbc, 0x7f,
80    0x36, 0x16, 0xa1, 0x72, 0x98, 0x6a, 0x44, 0x39, 0x42, 0x67, 0xb5, 0x4a, 0xac, 0x14, 0x35, 0x8f,
81    0xcd, 0x87, 0x3f, 0x9e, 0x2e, 0xa1, 0x53, 0xf1, 0x45, 0x68, 0x26, 0xcb, 0x35, 0x96, 0x57, 0xd5,
82    0x3a, 0x24, 0x74, 0xe2, 0xff, 0xe0, 0x70, 0xb1, 0xbd, 0xec, 0x0c, 0xd2, 0x97, 0x9a, 0xe5, 0x9f,
83    0xa9, 0xfe, 0x6a, 0x63, 0x17, 0x35, 0xad, 0x64, 0x2f, 0xd9, 0x2e, 0xdb, 0x47, 0xdc, 0x62, 0xdc,
84    0xcc, 0xee, 0x7e, 0x23, 0xa6, 0x67, 0x61, 0x7c, 0xd1, 0x03, 0xbd, 0x78, 0xe9, 0x34, 0x05, 0xed,
85    0x05, 0x87, 0xef, 0x59, 0xf4, 0x16, 0xd6, 0x8d, 0x85, 0x46, 0x65, 0x2a, 0x08, 0xac, 0x4a, 0x5d,
86    0xe6, 0x27, 0x5f, 0x43, 0xdd, 0x51, 0x4e, 0x95, 0x9b, 0xf5, 0x0c, 0x81, 0x24, 0x73, 0x39, 0x77,
87    0xe9, 0xc8, 0x35, 0x4a, 0xe2, 0xb8, 0x35, 0x92, 0xde, 0x5c, 0x31, 0x12, 0x36, 0x5c, 0xc7, 0x69,
88    0xcd, 0x79, 0xa9, 0xf9, 0xcf, 0x13, 0xa9, 0x12, 0x29, 0x25, 0x5c, 0x6a, 0x34, 0xa4, 0xbf, 0xc5,
89    0xb6, 0x2a, 0xc1, 0xba, 0x6a, 0xd3, 0x98, 0x8c, 0x9b, 0x6d, 0x9f, 0xb9, 0x25, 0xa6, 0xd1, 0x97,
90    0x80, 0x38, 0x11, 0xdc, 0x73, 0x5c, 0xe7, 0x3a, 0x1f, 0xd2, 0x16, 0xcd, 0x63, 0xfb, 0x41, 0xb0,
91    0xba, 0xb0, 0x38, 0x67, 0x48, 0xd2, 0x8a, 0x94, 0x2f, 0x11, 0x81, 0xbf, 0x66, 0x38, 0x68, 0xff,
92    0xfe, 0xd1, 0x7c, 0xcd, 0xa3, 0xac, 0xe4, 0xf7, 0x58, 0x19, 0xcd, 0x2a, 0xe3, 0xfa, 0x4d, 0xb0,
93    0xbe, 0xac, 0x05, 0x1c, 0xd9, 0x8d, 0xf7, 0x5c, 0xc0, 0xfc, 0xa6, 0xb5, 0x99, 0xb8, 0x8e, 0x2b,
94    0x72, 0xf8, 0x19, 0xfc, 0x17, 0x11, 0xf6, 0x2b, 0x08, 0xe4, 0x6e, 0xb0, 0x65, 0xab, 0x78, 0x8a,
95    0xfc, 0x7c, 0x09, 0xca, 0x73, 0xcd, 0x35, 0x5d, 0x6c, 0x7a, 0x36, 0xc0, 0x24, 0xba, 0x3f, 0x08,
96    0xea, 0x17, 0x09, 0xe1, 0x9d, 0x5d, 0x18, 0x59, 0x8a, 0xd8, 0x6a, 0x6d, 0x85, 0x6a, 0x9e, 0xa9,
97    0xe5, 0x4b, 0x45, 0xb2, 0x35, 0x6e, 0x62, 0x24, 0x08, 0x00, 0x1c, 0x06, 0x73, 0x27, 0x5d, 0x11,
98    0x4a, 0xc8, 0x51, 0xbd, 0x59, 0xd6, 0x94, 0xce, 0x16, 0x15, 0x17, 0x58, 0x7f, 0x39, 0x9d, 0x4e,
99    0x69, 0x1a, 0x64, 0xbb, 0xd4, 0x51, 0xb9, 0xe4, 0x7d, 0x51, 0x3a, 0xff, 0xe5, 0x1f, 0x29, 0xea,
100    0x7e, 0xa5, 0x62, 0x63, 0xff, 0x10, 0xf7, 0x54, 0x35, 0xd1, 0xf3, 0x73, 0x1e, 0xab, 0xca, 0x52,
101    0x14, 0xc6, 0x7e, 0x51, 0xc2, 0x48, 0x13, 0xcb, 0x30, 0xb2, 0x1a, 0x84, 0x72, 0xe5, 0x44, 0x83,
102    0xc9, 0x90, 0xa5, 0x8c, 0xf9, 0xeb, 0x3c, 0x5c, 0xc6, 0xcc, 0x8a, 0x95, 0x8a, 0xfa, 0xeb, 0x37,
103    0x9c, 0xde, 0xa2, 0xb1, 0x72, 0x4d, 0xd9, 0x3d, 0xab, 0xfd, 0x0e, 0xbd, 0x32, 0x9d, 0x23, 0xe9,
104    0x6f, 0x85, 0x4e, 0xfe, 0xcd, 0x91, 0xfb, 0x82, 0x94, 0xee, 0x8b, 0xdf, 0x6a, 0xd9, 0x01, 0xa1,
105    0xc6, 0x22, 0x18, 0x01, 0x8d, 0x10, 0xd5, 0x87, 0x42, 0xd0, 0xbd, 0x23, 0x75, 0x44, 0x53, 0x46,
106    0xa5, 0xae, 0x00, 0x4c, 0x0e, 0x88, 0x4a, 0xa8, 0x3d, 0x4a, 0x30, 0xe0, 0x1a, 0xa4, 0xe5, 0x40,
107    0xb8, 0xe0, 0x12, 0x9c, 0x44, 0x03, 0xfb, 0x2e, 0x4e, 0xf5, 0x29, 0xdb, 0x09, 0x84, 0x55, 0xc7,
108    0x6c, 0xc6, 0x1f, 0xf9, 0xee, 0x0b, 0xa4, 0x91, 0x7d, 0x79, 0x27, 0x59, 0x75, 0x97, 0xec, 0x6a,
109    0xa8, 0xf8, 0x55, 0xa8, 0x45, 0xd4, 0xd7, 0xa6, 0xc1, 0xc4, 0x27, 0x35, 0xe8, 0x4f, 0x39, 0x89,
110    0x7d, 0x41, 0xf3, 0xf6, 0xd0, 0xb6, 0xf9, 0x91, 0xeb, 0x94, 0xf1, 0xbb, 0x17, 0x46, 0x9c, 0xd5,
111    0x5a, 0x53, 0x04, 0x2d, 0x12, 0x7c, 0x17, 0x6a, 0x36, 0xb5, 0xea, 0xf3, 0x5b, 0x96, 0x1b, 0xee,
112    0xce, 0xc4, 0xc0, 0x11, 0x5a, 0xbc, 0x0c, 0x29, 0xd0, 0x42, 0x1d, 0x16, 0x63, 0xea, 0x1e, 0x04,
113    0x2f, 0xe3, 0x17, 0xed, 0x33, 0xac, 0x56, 0x80, 0x34, 0x41, 0x41, 0x1e, 0x77, 0x80, 0x06, 0x9f,
114    0xbc, 0x2e, 0x78, 0xa1, 0x04, 0x00, 0x06, 0x6f, 0x36, 0x2f, 0xb7, 0xa5, 0x95, 0x37, 0x82, 0x9d,
115    0xef, 0x41, 0x08, 0x85, 0x3d, 0x53, 0xa7, 0xfb, 0xfe, 0xba, 0x8c, 0xb9, 0xae, 0xc7, 0x89, 0x11,
116    0x69, 0x4f, 0x62, 0xe6, 0xb6, 0x08, 0x6b, 0x35, 0x1c, 0x96, 0xb3, 0x7b, 0x40, 0x2d, 0xee, 0x07,
117    0x40, 0x52, 0x4f, 0x68, 0x60, 0xf4, 0xb9, 0xc3, 0x54, 0x9f, 0x22, 0x50, 0x88, 0x48, 0x6a, 0x28,
118    0x93, 0x46, 0x00, 0xe2, 0x4a, 0x85, 0x41, 0x78, 0x0e, 0x87, 0xc5, 0xeb, 0xfc, 0xd3, 0x5f, 0x4d,
119    0x24, 0xe4, 0x9d, 0xeb, 0x1d, 0x00, 0x73, 0x85, 0x25, 0x47, 0x9e, 0x8c, 0x5b, 0x88, 0xf4, 0x3b,
120    0x33, 0xf0, 0x3d, 0x3a, 0xa1, 0x28, 0xd3, 0x06, 0xb4, 0x7a, 0x4e, 0x5d, 0x31, 0x1b, 0xca, 0xf4,
121    0x3f, 0x70, 0x30, 0x49, 0x44, 0x29, 0x24, 0x14, 0x5e, 0x35, 0xc2, 0x6c, 0x92, 0x7e, 0xf8, 0x97,
122    0x0c, 0x51, 0x9d, 0x67, 0xc0, 0x10, 0xa9, 0x35, 0x48, 0x59, 0x6a, 0x33, 0xef, 0x40, 0x4e, 0x53,
123    0x10, 0x14, 0x2a, 0x12, 0x38, 0xe6, 0xc4, 0x63, 0x9c, 0x84, 0x85, 0x06, 0xaf, 0x3d, 0x3a, 0x84,
124    0x06, 0x60, 0x88, 0x32, 0xda, 0x2c, 0xe5, 0xc6, 0x59, 0xf1, 0xe0, 0x10, 0xe2, 0x3c, 0xe6, 0xbf,
125    0x32, 0x7d, 0x32, 0x39, 0x6d, 0xe4, 0xd9, 0xca, 0xe7, 0xf5, 0xf4, 0xa6, 0x5f, 0xb2, 0x33, 0x05,
126    0xb5, 0xad, 0x5f, 0xcb, 0x0b, 0x14, 0xaf, 0xeb, 0xc0, 0xec, 0x87, 0x85, 0x9b, 0x13, 0xb5, 0x8a,
127    0x98, 0xa9, 0x92, 0x13, 0x1b, 0x74, 0xec, 0xfd, 0xe1, 0xc1, 0x22, 0x06, 0x5d, 0x4f, 0x06, 0xc7,
128    0xdd, 0xc6, 0xf0, 0xc4, 0x01, 0x04, 0xad, 0x7f, 0x71, 0xbc, 0x74, 0x4d, 0xfd, 0x18, 0xa3, 0x56,
129    0x2c, 0x45, 0x28, 0x2b, 0x2f, 0xbc, 0x9b, 0xb8, 0x4b, 0xe6, 0x51, 0x75, 0x28, 0x0c, 0x27, 0x0e,
130    0xf7, 0x92, 0x8c, 0xc9, 0xde, 0x33, 0x1a, 0x65, 0x28, 0xc7, 0x01, 0x32, 0xa2, 0x36, 0x88, 0xb6,
131    0x64, 0x10, 0x03, 0xd6, 0xb7, 0x9f, 0x9d, 0x73, 0xe1, 0xa9, 0xc7, 0xdf, 0xe1, 0x0b, 0x39, 0x31,
132    0x77, 0xbc, 0x91, 0xf1, 0x45, 0x9a, 0xc5, 0x97, 0x28, 0xc0, 0x61, 0xc5, 0x23, 0x54, 0xad, 0xe3,
133    0x23, 0x18, 0x69, 0xf7, 0x27, 0xd0, 0x5b, 0xf2, 0x44, 0x62, 0xdc, 0x97, 0xce, 0x4e, 0x40, 0x76,
134    0x00, 0xde, 0xc2, 0xf9, 0x3a, 0x42, 0xfd, 0xd4, 0xd7, 0xe1, 0x85, 0xd8, 0xc9, 0x38, 0x91, 0xc1,
135    0x79, 0x87, 0x58, 0xf1, 0x26, 0x1a, 0x29, 0x02, 0xe3, 0x54, 0xde, 0x58, 0x64, 0x9d, 0xe6, 0x8e,
136    0x33, 0x70, 0x53, 0x43, 0x47, 0x90, 0xee, 0x6e, 0x0f, 0x8c, 0xb3, 0x9e, 0x47, 0x45, 0xfc, 0xa8,
137    0xe3, 0x52, 0x62, 0x74, 0x6d, 0xa2, 0xaf, 0x28, 0x9d, 0xdf, 0x1e, 0x69, 0x1f, 0x56, 0xbc, 0x49,
138    0xc1, 0xe5, 0xd6, 0xc4, 0xb5, 0x5c, 0x4d, 0x39, 0x49, 0x4b, 0xb4, 0xec, 0x56, 0x54, 0x9a, 0x15,
139    0x94, 0x0a, 0xcb, 0xa9, 0x10, 0x46, 0x03, 0x5c, 0x23, 0x2f, 0x29, 0xed, 0x72, 0xa1, 0x57, 0xfa,
140    0x58, 0xef, 0x21, 0x7e, 0xf2, 0x8b, 0xa7, 0x04, 0x51, 0xb4, 0x03, 0x5d, 0xd8, 0x48, 0xc0, 0xe5,
141    0x83, 0xb6, 0x7a, 0x6b, 0xcd, 0xfb, 0xda, 0x47, 0xe8, 0xa1, 0xae, 0x57, 0x74, 0x49, 0xc0, 0xf9,
142    0x4a, 0x6b, 0x3c, 0xb5, 0xd8, 0x27, 0x3d, 0x1d, 0x96, 0x39, 0x09, 0x65, 0x95, 0xdb, 0x01, 0xa3,
143    0x8b, 0x78, 0x8b, 0x07, 0x6d, 0x1c, 0x8b, 0x4b, 0x1d, 0x9d, 0x4a, 0x4f, 0xcb, 0xb8, 0xf6, 0x22,
144    0x73, 0x8a, 0x7b, 0xc8, 0xf2, 0x0a, 0xef, 0x03, 0x1e, 0xb7, 0x4d, 0x8f, 0xc0, 0xdf, 0x87, 0x88,
145    0x05, 0xe1, 0x0a, 0x30, 0xea, 0xde, 0xf3, 0xc2, 0xb6, 0x00, 0x3c, 0xd6, 0xff, 0x3b, 0xb5, 0x01,
146    0xfb, 0xd8, 0xb2, 0x65, 0x26, 0x5d, 0xa0, 0x5a, 0x7c, 0xef, 0x1d, 0x85, 0xbe, 0x51, 0xc2, 0x57,
147    0x0b, 0x27, 0x37, 0x71, 0x99, 0xf5, 0x87, 0x83, 0x68, 0x0b, 0x88, 0xed, 0x66, 0x9e, 0x37, 0x59,
148    0x84, 0x23, 0x72, 0xc3, 0x80, 0xac, 0xfe, 0x45, 0x5f, 0xdf, 0x31, 0xc4, 0x84, 0x07, 0x5a, 0x17,
149    0x28, 0xcd, 0x64, 0xb4, 0xe2, 0xa3, 0x0e, 0x2c, 0x15, 0x60, 0x77, 0xdc, 0x08, 0x45, 0x36, 0x37,
150    0x68, 0x50, 0xba, 0x03, 0x85, 0xb7, 0xed, 0xd0, 0x7b, 0xb2, 0xa1, 0x62, 0xbc, 0x70, 0x00, 0x9e,
151];
152
153/// Result of checking whether an update is available.
154#[derive(Debug, Clone, Serialize, Deserialize)]
155pub struct UpdateCheck {
156    pub current_version: String,
157    pub latest_version: String,
158    pub update_available: bool,
159    pub download_url: Option<String>,
160    /// Release channel the check was performed against.
161    ///
162    /// `#[serde(default)]` keeps `--json` output from pre-channel versions deserializing.
163    #[serde(default = "default_channel")]
164    pub channel: UpgradeChannel,
165}
166
167/// Channel assumed when deserializing an `UpdateCheck` written before channels existed.
168fn default_channel() -> UpgradeChannel {
169    UpgradeChannel::Stable
170}
171
172/// Infer the release channel a running binary belongs to from its own version.
173///
174/// A build whose version carries a `beta` pre-release identifier is a beta build and stays
175/// on the beta channel unless told otherwise; everything else is stable. This keeps a beta
176/// user on beta with no stored configuration — the alternative, defaulting to stable, would
177/// self-update a beta build straight back onto the stable train.
178#[must_use]
179pub fn channel_for_version(version: &str) -> UpgradeChannel {
180    match parse_version(version) {
181        Ok(v) if v.pre.as_str().split('.').next() == Some("beta") => UpgradeChannel::Beta,
182        _ => UpgradeChannel::Stable,
183    }
184}
185
186impl UpdateCheck {
187    /// Force the check to report an update even if already on the latest version.
188    ///
189    /// Populates `download_url` using the current `latest_version`.
190    pub fn force(&mut self) -> Result<()> {
191        self.update_available = true;
192        self.download_url = Some(build_download_url(&self.latest_version)?);
193        Ok(())
194    }
195}
196
197/// Result of a completed update.
198#[derive(Debug, Clone, Serialize, Deserialize)]
199pub struct UpdateResult {
200    pub previous_version: String,
201    pub new_version: String,
202}
203
204/// Outcome of an update declined because a package manager owns this installation.
205///
206/// Reported instead of [`UpdateResult`] when `ant` was installed by a package manager: the
207/// version check still runs, so the caller learns whether an update exists, but the binary is
208/// left alone and `update_command` says how to get it.
209#[derive(Debug, Clone, Serialize, Deserialize)]
210pub struct DeferredUpdate {
211    pub current_version: String,
212    pub latest_version: String,
213    pub update_available: bool,
214    pub channel: UpgradeChannel,
215    /// How this copy of `ant` was installed.
216    pub install_method: InstallMethod,
217    /// The command that will perform the update.
218    pub update_command: String,
219}
220
221impl DeferredUpdate {
222    /// Build the deferral report for `check` under `method`.
223    ///
224    /// Returns `None` for an install `ant update` may replace itself.
225    #[must_use]
226    pub fn new(check: &UpdateCheck, method: InstallMethod) -> Option<Self> {
227        Some(Self {
228            current_version: check.current_version.clone(),
229            latest_version: check.latest_version.clone(),
230            update_available: check.update_available,
231            channel: check.channel,
232            install_method: method,
233            update_command: method.update_command()?,
234        })
235    }
236}
237
238/// Check whether a newer version is available on GitHub Releases.
239///
240/// Compares `current_version` against the highest release tag eligible for `channel`,
241/// using semantic versioning. See [`crate::channel::version_matches_channel`] for which
242/// tags each channel accepts.
243pub async fn check_for_update(
244    current_version: &str,
245    channel: UpgradeChannel,
246) -> Result<UpdateCheck> {
247    let latest = fetch_latest_cli_version(channel).await?;
248    let current = parse_version(current_version)?;
249    let latest_parsed = parse_version(&latest)?;
250    let update_available = latest_parsed > current;
251
252    let download_url = if update_available {
253        Some(build_download_url(&latest)?)
254    } else {
255        None
256    };
257
258    Ok(UpdateCheck {
259        current_version: current_version.to_string(),
260        latest_version: latest,
261        update_available,
262        download_url,
263        channel,
264    })
265}
266
267/// Download and install the update, replacing the current binary.
268///
269/// Downloads the release archive and its detached `.sig` file, verifies the
270/// ML-DSA-65 signature against the embedded release key, extracts the binary,
271/// and replaces the running executable.
272pub async fn perform_update(
273    check: &UpdateCheck,
274    progress: &dyn ProgressReporter,
275) -> Result<UpdateResult> {
276    // Defence in depth. `ant update` checks this before it gets here and prints the right
277    // instruction, but any other consumer of this crate reaches self-replacement through this
278    // function, and overwriting a package-manager-owned binary leaves that manager's metadata
279    // describing a file that no longer exists — the next `npm update` would then silently roll
280    // the user back to the older build.
281    let method = install::detect();
282    if !method.can_self_replace() {
283        let command = method
284            .update_command()
285            .unwrap_or_else(|| "your package manager".to_string());
286        return Err(Error::UpdateFailed(format!(
287            "this ant was installed by a package manager, so it cannot replace itself. \
288             Update it with: {command}"
289        )));
290    }
291
292    let download_url = check.download_url.as_deref().ok_or_else(|| {
293        Error::UpdateFailed("no download URL — are you already on the latest version?".to_string())
294    })?;
295
296    let tmp_dir = tempfile::tempdir()
297        .map_err(|e| Error::UpdateFailed(format!("failed to create temp directory: {e}")))?;
298
299    let (archive_path, archive_bytes) =
300        download_archive(download_url, tmp_dir.path(), progress).await?;
301
302    let sig_url = format!("{download_url}.sig");
303    progress.report_started("Downloading signature...");
304    let sig_bytes = download_bytes(&sig_url).await?;
305
306    progress.report_started("Verifying ML-DSA signature...");
307    verify_signature(&archive_bytes, &sig_bytes)?;
308    progress.report_complete("Signature verified");
309
310    progress.report_started("Extracting archive...");
311    let extracted = if download_url.ends_with(".zip") {
312        extract_zip(&archive_bytes, tmp_dir.path(), CLI_BINARY_NAME)?
313    } else {
314        extract_tar_gz(&archive_bytes, tmp_dir.path(), CLI_BINARY_NAME)?
315    };
316    let binary_path = extracted.binary_path;
317
318    // Verify the extracted binary reports the expected version.
319    let actual_version = extract_version(&binary_path).await;
320    if let Ok(ref v) = actual_version {
321        if v != &check.latest_version {
322            return Err(Error::UpdateFailed(format!(
323                "version mismatch: expected {}, binary reports {v}",
324                check.latest_version
325            )));
326        }
327    }
328
329    replace_binary(&binary_path)?;
330
331    // Clean up is handled by tmp_dir Drop, but remove the archive explicitly
332    // since it can be large.
333    let _ = std::fs::remove_file(&archive_path);
334
335    Ok(UpdateResult {
336        previous_version: check.current_version.clone(),
337        new_version: check.latest_version.clone(),
338    })
339}
340
341/// Fetch the highest CLI release version eligible for `channel` from GitHub.
342///
343/// Lists all releases and finds the newest non-draft one whose tag starts with
344/// `ant-cli-v` and whose version is eligible for `channel`.
345async fn fetch_latest_cli_version(channel: UpgradeChannel) -> Result<String> {
346    // per_page=100 is the GitHub API maximum; covers repos with many release tags.
347    let url = format!("https://api.github.com/repos/{GITHUB_REPO}/releases?per_page=100");
348    let client = reqwest::Client::new();
349    let resp = client
350        .get(&url)
351        .header("User-Agent", "ant-cli")
352        .header("Accept", "application/vnd.github+json")
353        .send()
354        .await
355        .map_err(|e| Error::UpdateFailed(format!("failed to fetch releases: {e}")))?;
356
357    if !resp.status().is_success() {
358        return Err(Error::UpdateFailed(format!(
359            "GitHub API returned status {} when fetching releases",
360            resp.status()
361        )));
362    }
363
364    let releases: Vec<serde_json::Value> = resp
365        .json()
366        .await
367        .map_err(|e| Error::UpdateFailed(format!("failed to parse releases JSON: {e}")))?;
368
369    select_channel_version(&releases, channel).ok_or_else(|| {
370        Error::UpdateFailed(format!(
371            "no ant-cli release found on GitHub for the {channel} channel"
372        ))
373    })
374}
375
376/// Pick the highest `ant-cli-v*` version from a GitHub releases payload that is eligible
377/// for `channel`.
378///
379/// Draft releases are skipped. GitHub's `prerelease` flag is deliberately not consulted:
380/// the tag's own semver pre-release component is the authority, so the rule here is the
381/// same one the node applies to its own upgrades. Filtering on the flag instead is what
382/// made a beta `ant` self-update back onto stable.
383fn select_channel_version(
384    releases: &[serde_json::Value],
385    channel: UpgradeChannel,
386) -> Option<String> {
387    let mut best: Option<semver::Version> = None;
388
389    for release in releases {
390        if release["draft"].as_bool().unwrap_or(false) {
391            continue;
392        }
393
394        let tag = release["tag_name"].as_str().unwrap_or_default();
395        let Some(version_str) = tag.strip_prefix(TAG_PREFIX) else {
396            continue;
397        };
398        let Ok(version) = semver::Version::parse(version_str) else {
399            continue;
400        };
401
402        if !version_matches_channel(&version, channel) {
403            continue;
404        }
405
406        if best.as_ref().is_none_or(|b| version > *b) {
407            best = Some(version);
408        }
409    }
410
411    best.map(|v| v.to_string())
412}
413
414/// Download a release archive to a temp directory, streaming to disk.
415///
416/// Returns the path to the downloaded file and the raw bytes (needed for
417/// signature verification before extraction).
418async fn download_archive(
419    url: &str,
420    tmp_dir: &Path,
421    progress: &dyn ProgressReporter,
422) -> Result<(PathBuf, Vec<u8>)> {
423    progress.report_started(&format!("Downloading {CLI_BINARY_NAME} from {url}"));
424
425    let client = reqwest::Client::new();
426    let resp = client
427        .get(url)
428        .header("User-Agent", "ant-cli")
429        .send()
430        .await
431        .map_err(|e| Error::UpdateFailed(format!("download request failed: {e}")))?;
432
433    if !resp.status().is_success() {
434        return Err(Error::UpdateFailed(format!(
435            "download returned status {}",
436            resp.status()
437        )));
438    }
439
440    let total_size = resp.content_length().unwrap_or(0);
441    let mut downloaded: u64 = 0;
442
443    let tmp_archive = tmp_dir.join(".download.tmp");
444    let mut tmp_file = std::fs::File::create(&tmp_archive)
445        .map_err(|e| Error::UpdateFailed(format!("failed to create temp file: {e}")))?;
446
447    let mut stream = resp.bytes_stream();
448    while let Some(chunk) = stream.next().await {
449        let chunk =
450            chunk.map_err(|e| Error::UpdateFailed(format!("download stream error: {e}")))?;
451        downloaded += chunk.len() as u64;
452        std::io::Write::write_all(&mut tmp_file, &chunk)
453            .map_err(|e| Error::UpdateFailed(format!("failed to write temp file: {e}")))?;
454        progress.report_progress(downloaded, total_size);
455    }
456    drop(tmp_file);
457
458    progress.report_complete("Download complete");
459
460    let bytes = std::fs::read(&tmp_archive)
461        .map_err(|e| Error::UpdateFailed(format!("failed to read temp file: {e}")))?;
462
463    Ok((tmp_archive, bytes))
464}
465
466/// Download a small file (e.g., a `.sig` signature) into memory.
467async fn download_bytes(url: &str) -> Result<Vec<u8>> {
468    let client = reqwest::Client::new();
469    let resp = client
470        .get(url)
471        .header("User-Agent", "ant-cli")
472        .send()
473        .await
474        .map_err(|e| Error::UpdateFailed(format!("download request failed: {e}")))?;
475
476    if !resp.status().is_success() {
477        return Err(Error::UpdateFailed(format!(
478            "download returned status {} for {url}",
479            resp.status()
480        )));
481    }
482
483    resp.bytes()
484        .await
485        .map(|b| b.to_vec())
486        .map_err(|e| Error::UpdateFailed(format!("failed to read response body: {e}")))
487}
488
489/// Verify the ML-DSA-65 signature on an archive using the embedded release key.
490fn verify_signature(archive_bytes: &[u8], signature_bytes: &[u8]) -> Result<()> {
491    if signature_bytes.len() != SIGNATURE_SIZE {
492        return Err(Error::UpdateFailed(format!(
493            "invalid signature size: expected {SIGNATURE_SIZE}, got {}",
494            signature_bytes.len()
495        )));
496    }
497
498    let public_key = MlDsaPublicKey::from_bytes(MlDsaVariant::MlDsa65, RELEASE_SIGNING_KEY)
499        .map_err(|e| Error::UpdateFailed(format!("invalid embedded release key: {e}")))?;
500
501    let sig = MlDsaSignature::from_bytes(MlDsaVariant::MlDsa65, signature_bytes)
502        .map_err(|e| Error::UpdateFailed(format!("invalid signature format: {e}")))?;
503
504    let dsa = ml_dsa_65();
505    let valid = dsa
506        .verify_with_context(&public_key, archive_bytes, &sig, SIGNING_CONTEXT)
507        .map_err(|e| Error::UpdateFailed(format!("signature verification error: {e}")))?;
508
509    if valid {
510        Ok(())
511    } else {
512        Err(Error::UpdateFailed(
513            "signature verification failed: archive may be corrupted or tampered".to_string(),
514        ))
515    }
516}
517
518/// Extract the version string from a binary by running `<binary> --version`.
519async fn extract_version(binary_path: &Path) -> Result<String> {
520    let mut cmd = tokio::process::Command::new(binary_path);
521    cmd.arg("--version");
522    // CREATE_NO_WINDOW: prevents Windows from allocating a console window for
523    // the console-subsystem child binary. Without this, every version probe
524    // flashes a window — visible as "ghost flashes" in GUI consumers.
525    #[cfg(windows)]
526    {
527        const CREATE_NO_WINDOW: u32 = 0x08000000;
528        cmd.creation_flags(CREATE_NO_WINDOW);
529    }
530    let output = cmd.output().await.map_err(|e| {
531        Error::UpdateFailed(format!(
532            "failed to run {} --version: {e}",
533            binary_path.display()
534        ))
535    })?;
536
537    if !output.status.success() {
538        return Err(Error::UpdateFailed(format!(
539            "{} --version exited with status {}",
540            binary_path.display(),
541            output.status
542        )));
543    }
544
545    let stdout = String::from_utf8_lossy(&output.stdout);
546    Ok(parse_version_from_stdout(&stdout).to_string())
547}
548
549/// Extract the version token from `--version` stdout.
550///
551/// Only the first line is inspected: historically `ant --version` was a single
552/// line like `ant 0.1.4`, but a richer multi-line long-version string ending
553/// with `License: MIT or Apache-2.0` shipped briefly and caused this parser —
554/// in 0.1.2 through 0.1.4 — to pick up `Apache-2.0` as the version. Scoping
555/// to the first line keeps us compatible with both forms.
556fn parse_version_from_stdout(stdout: &str) -> &str {
557    stdout
558        .lines()
559        .next()
560        .unwrap_or_default()
561        .split_whitespace()
562        .last()
563        .unwrap_or("unknown")
564}
565
566/// Replace the current executable with the new binary.
567///
568/// Uses the `self_replace` crate which handles platform-specific nuances,
569/// particularly on Windows where the running executable is locked.
570fn replace_binary(new_binary: &Path) -> Result<()> {
571    self_replace::self_replace(new_binary)
572        .map_err(|e| Error::UpdateFailed(format!("failed to replace binary: {e}")))?;
573    Ok(())
574}
575
576/// Build the download URL for a given version.
577///
578/// Tag format: `ant-cli-v{version}`
579/// Asset format: `ant-{version}-{target_triple}.{ext}`
580pub fn build_download_url(version: &str) -> Result<String> {
581    let asset_name = cli_platform_asset_name(version)?;
582    Ok(format!(
583        "https://github.com/{GITHUB_REPO}/releases/download/{TAG_PREFIX}{version}/{asset_name}"
584    ))
585}
586
587/// Returns the platform-specific archive asset name for the CLI binary.
588///
589/// Matches the naming convention from CI: `ant-{version}-{target_triple}.{ext}`
590fn cli_platform_asset_name(version: &str) -> Result<String> {
591    let target_triple = if cfg!(all(target_os = "linux", target_arch = "x86_64")) {
592        "x86_64-unknown-linux-musl"
593    } else if cfg!(all(target_os = "linux", target_arch = "aarch64")) {
594        "aarch64-unknown-linux-musl"
595    } else if cfg!(all(target_os = "macos", target_arch = "x86_64")) {
596        "x86_64-apple-darwin"
597    } else if cfg!(all(target_os = "macos", target_arch = "aarch64")) {
598        "aarch64-apple-darwin"
599    } else if cfg!(all(target_os = "windows", target_arch = "x86_64")) {
600        "x86_64-pc-windows-msvc"
601    } else {
602        return Err(Error::UpdateFailed(format!(
603            "unsupported platform: {}-{}",
604            std::env::consts::OS,
605            std::env::consts::ARCH
606        )));
607    };
608
609    let ext = if cfg!(target_os = "windows") {
610        "zip"
611    } else {
612        "tar.gz"
613    };
614
615    Ok(format!("ant-{version}-{target_triple}.{ext}"))
616}
617
618fn parse_version(version: &str) -> Result<semver::Version> {
619    let cleaned = version.strip_prefix('v').unwrap_or(version);
620    semver::Version::parse(cleaned)
621        .map_err(|e| Error::UpdateFailed(format!("invalid version '{version}': {e}")))
622}
623
624#[cfg(test)]
625mod tests {
626    use super::*;
627
628    #[test]
629    fn parse_version_from_stdout_single_line() {
630        assert_eq!(parse_version_from_stdout("ant 0.1.4"), "0.1.4");
631        assert_eq!(parse_version_from_stdout("ant 0.1.4\n"), "0.1.4");
632    }
633
634    #[test]
635    fn parse_version_from_stdout_multi_line_license_trailer() {
636        // This is the exact shape that broke 0.1.2–0.1.4 self-update.
637        let stdout = "ant 0.1.4\n\
638            Autonomi network client\n\
639            \n\
640            Repository: https://github.com/WithAutonomi/ant-client\n\
641            License:    MIT or Apache-2.0\n";
642        assert_eq!(parse_version_from_stdout(stdout), "0.1.4");
643    }
644
645    #[test]
646    fn parse_version_from_stdout_empty() {
647        assert_eq!(parse_version_from_stdout(""), "unknown");
648    }
649
650    #[test]
651    fn parse_version_from_stdout_blank_first_line() {
652        assert_eq!(parse_version_from_stdout("\nant 0.1.4\n"), "unknown");
653    }
654
655    #[test]
656    fn parse_version_from_stdout_leading_whitespace() {
657        assert_eq!(parse_version_from_stdout("   ant 0.1.4\n"), "0.1.4");
658    }
659
660    #[test]
661    fn parse_version_valid() {
662        assert!(parse_version("1.2.3").is_ok());
663        assert!(parse_version("v1.2.3").is_ok());
664        assert!(parse_version("0.1.0").is_ok());
665    }
666
667    #[test]
668    fn parse_version_invalid() {
669        assert!(parse_version("not-a-version").is_err());
670        assert!(parse_version("").is_err());
671    }
672
673    #[test]
674    fn version_comparison() {
675        let v1 = parse_version("0.1.0").unwrap();
676        let v2 = parse_version("0.2.0").unwrap();
677        assert!(v2 > v1);
678
679        let v3 = parse_version("1.0.0").unwrap();
680        assert!(v3 > v2);
681
682        let same = parse_version("0.1.0").unwrap();
683        assert_eq!(v1, same);
684    }
685
686    #[test]
687    fn check_result_no_update() {
688        let check = UpdateCheck {
689            current_version: "1.0.0".to_string(),
690            latest_version: "1.0.0".to_string(),
691            update_available: false,
692            download_url: None,
693            channel: UpgradeChannel::Stable,
694        };
695        assert!(!check.update_available);
696        assert!(check.download_url.is_none());
697    }
698
699    #[test]
700    fn check_result_with_update() {
701        let check = UpdateCheck {
702            current_version: "0.1.0".to_string(),
703            latest_version: "0.2.0".to_string(),
704            update_available: true,
705            download_url: Some("https://example.com/ant.tar.gz".to_string()),
706            channel: UpgradeChannel::Stable,
707        };
708        assert!(check.update_available);
709        assert!(check.download_url.is_some());
710    }
711
712    #[test]
713    fn force_populates_download_url() {
714        let mut check = UpdateCheck {
715            current_version: "1.0.0".to_string(),
716            latest_version: "1.0.0".to_string(),
717            update_available: false,
718            download_url: None,
719            channel: UpgradeChannel::Stable,
720        };
721        check.force().unwrap();
722        assert!(check.update_available);
723        assert!(check.download_url.is_some());
724    }
725
726    #[test]
727    fn platform_asset_name_format() {
728        let name = cli_platform_asset_name("1.2.3").unwrap();
729        assert!(name.starts_with("ant-1.2.3-"));
730        assert!(
731            name.ends_with(".tar.gz") || name.ends_with(".zip"),
732            "unexpected extension: {name}"
733        );
734    }
735
736    #[test]
737    fn build_download_url_format() {
738        let url = build_download_url("1.2.3").unwrap();
739        assert!(url.starts_with(
740            "https://github.com/WithAutonomi/ant-client/releases/download/ant-cli-v1.2.3/ant-1.2.3-"
741        ));
742        assert!(url.ends_with(".tar.gz") || url.ends_with(".zip"));
743    }
744
745    #[test]
746    fn update_check_serializes() {
747        let check = UpdateCheck {
748            current_version: "0.1.0".to_string(),
749            latest_version: "0.2.0".to_string(),
750            update_available: true,
751            download_url: Some("https://example.com/ant.tar.gz".to_string()),
752            channel: UpgradeChannel::Stable,
753        };
754        let json = serde_json::to_string(&check).unwrap();
755        let deserialized: UpdateCheck = serde_json::from_str(&json).unwrap();
756        assert_eq!(deserialized.current_version, "0.1.0");
757        assert!(deserialized.update_available);
758    }
759
760    #[test]
761    fn verify_signature_rejects_wrong_size() {
762        let result = verify_signature(b"some archive data", &[0u8; 100]);
763        assert!(result.is_err());
764        let err = result.unwrap_err().to_string();
765        assert!(err.contains("invalid signature size"), "got: {err}");
766    }
767
768    #[test]
769    fn verify_signature_rejects_invalid_signature() {
770        let invalid_sig = vec![0u8; SIGNATURE_SIZE];
771        let result = verify_signature(b"some archive data", &invalid_sig);
772        assert!(result.is_err());
773    }
774
775    #[test]
776    fn verify_signature_valid_roundtrip() {
777        let dsa = ml_dsa_65();
778        let (public_key, secret_key) = dsa.generate_keypair().unwrap();
779        let archive = b"fake archive content for testing";
780
781        let sig = dsa
782            .sign_with_context(&secret_key, archive, SIGNING_CONTEXT)
783            .unwrap();
784
785        // Use the _with_key variant for testing (can't use embedded key)
786        let parsed_sig =
787            MlDsaSignature::from_bytes(MlDsaVariant::MlDsa65, &sig.to_bytes()).unwrap();
788        let valid = dsa
789            .verify_with_context(&public_key, archive, &parsed_sig, SIGNING_CONTEXT)
790            .unwrap();
791        assert!(valid);
792    }
793
794    fn release(tag: &str) -> serde_json::Value {
795        serde_json::json!({ "tag_name": tag, "draft": false })
796    }
797
798    #[test]
799    fn stable_selects_the_highest_final_release() {
800        let releases = [
801            release("ant-cli-v0.3.3"),
802            release("ant-cli-v0.3.4-beta.1"),
803            release("ant-cli-v0.3.4-rc.1"),
804        ];
805
806        assert_eq!(
807            select_channel_version(&releases, UpgradeChannel::Stable),
808            Some("0.3.3".to_string())
809        );
810    }
811
812    #[test]
813    fn beta_selects_the_beta_over_the_rc() {
814        let releases = [
815            release("ant-cli-v0.3.3"),
816            release("ant-cli-v0.3.4-beta.1"),
817            release("ant-cli-v0.3.4-rc.1"),
818        ];
819
820        assert_eq!(
821            select_channel_version(&releases, UpgradeChannel::Beta),
822            Some("0.3.4-beta.1".to_string())
823        );
824    }
825
826    /// The `prerelease` flag is not consulted: a beta release carries it, and skipping on it
827    /// is exactly what dragged a beta `ant` back onto stable.
828    #[test]
829    fn the_github_prerelease_flag_does_not_hide_a_beta() {
830        let mut beta = release("ant-cli-v0.3.4-beta.1");
831        beta["prerelease"] = serde_json::Value::Bool(true);
832        let releases = [release("ant-cli-v0.3.3"), beta];
833
834        assert_eq!(
835            select_channel_version(&releases, UpgradeChannel::Beta),
836            Some("0.3.4-beta.1".to_string())
837        );
838    }
839
840    #[test]
841    fn drafts_and_foreign_tags_are_skipped() {
842        let mut draft = release("ant-cli-v0.4.0");
843        draft["draft"] = serde_json::Value::Bool(true);
844        let releases = [
845            release("ant-core-v0.9.0"),
846            release("v0.5.0"),
847            draft,
848            release("ant-cli-v0.3.3"),
849        ];
850
851        assert_eq!(
852            select_channel_version(&releases, UpgradeChannel::Stable),
853            Some("0.3.3".to_string())
854        );
855    }
856
857    #[test]
858    fn no_eligible_release_yields_none() {
859        let releases = [release("ant-cli-v0.3.4-rc.1")];
860
861        assert_eq!(
862            select_channel_version(&releases, UpgradeChannel::Beta),
863            None
864        );
865    }
866
867    #[test]
868    fn channel_is_inferred_from_the_running_version() {
869        assert_eq!(channel_for_version("0.3.4-beta.1"), UpgradeChannel::Beta);
870        assert_eq!(channel_for_version("0.3.4-beta"), UpgradeChannel::Beta);
871        assert_eq!(channel_for_version("0.3.3"), UpgradeChannel::Stable);
872        assert_eq!(channel_for_version("0.3.4-rc.1"), UpgradeChannel::Stable);
873        assert_eq!(channel_for_version("0.3.4-alpha.1"), UpgradeChannel::Stable);
874        // `betamax` must not be inferred as beta by a prefix match.
875        assert_eq!(
876            channel_for_version("0.3.4-betamax.1"),
877            UpgradeChannel::Stable
878        );
879        assert_eq!(channel_for_version("not-a-version"), UpgradeChannel::Stable);
880    }
881
882    /// A `--json` payload written before the channel field existed still deserializes.
883    #[test]
884    fn update_check_deserializes_without_a_channel_field() {
885        let json = r#"{"current_version":"0.1.0","latest_version":"0.2.0",
886            "update_available":true,"download_url":null}"#;
887        let check: UpdateCheck = serde_json::from_str(json).unwrap();
888        assert_eq!(check.channel, UpgradeChannel::Stable);
889    }
890}