1use std::path::PathBuf;
4
5use anyhow::{anyhow, bail, Context, Result};
6use base64::{engine::general_purpose::STANDARD as BASE64_STANDARD, Engine};
7use chrono::Utc;
8use clap::{ArgAction, Args, Subcommand};
9use memvid_core::types::MemoryBinding;
10use memvid_core::{verify_ticket_signature, Memvid, Ticket};
11use serde_json::json;
12use uuid::Uuid;
13
14use crate::api::{
15 apply_ticket as api_apply_ticket, fetch_ticket as api_fetch_ticket,
16 ApplyTicketRequest as ApiApplyTicketRequest,
17};
18use crate::commands::{apply_ticket_with_warning, LockCliArgs};
19use crate::config::CliConfig;
20use crate::ticket_cache::{load as load_cached_ticket, store as store_cached_ticket, CachedTicket};
21use crate::utils::{apply_lock_cli, open_read_only_mem};
22
23#[derive(Subcommand)]
25pub enum TicketsCommand {
26 List(TicketsStatusArgs),
28 Issue(TicketsIssueArgs),
30 Revoke(TicketsRevokeArgs),
32 Sync(TicketsSyncArgs),
34 Apply(TicketsApplyArgs),
36}
37
38#[derive(Args)]
40pub struct TicketsArgs {
41 #[command(subcommand)]
42 pub command: TicketsCommand,
43}
44
45#[derive(Args)]
47pub struct TicketsSyncArgs {
48 #[arg(value_name = "FILE", value_parser = clap::value_parser!(PathBuf))]
49 pub file: PathBuf,
50 #[arg(long = "memory-id", value_name = "UUID", value_parser = clap::value_parser!(Uuid))]
51 pub memory_id: Uuid,
52 #[arg(long)]
53 pub json: bool,
54
55 #[command(flatten)]
56 pub lock: LockCliArgs,
57}
58
59#[derive(Args)]
61pub struct TicketsApplyArgs {
62 #[arg(value_name = "FILE", value_parser = clap::value_parser!(PathBuf))]
63 pub file: PathBuf,
64 #[arg(long = "memory-id", value_name = "UUID", value_parser = clap::value_parser!(Uuid))]
65 pub memory_id: Uuid,
66 #[arg(long = "from-api", action = ArgAction::SetTrue)]
67 pub from_api: bool,
68 #[arg(long)]
69 pub json: bool,
70}
71
72#[derive(Args)]
74pub struct TicketsIssueArgs {
75 #[arg(value_name = "FILE", value_parser = clap::value_parser!(PathBuf))]
76 pub file: PathBuf,
77 #[arg(long)]
78 pub issuer: String,
79 #[arg(long, value_name = "SEQ")]
80 pub seq: i64,
81 #[arg(long = "expires-in", value_name = "SECS")]
82 pub expires_in: Option<u64>,
83 #[arg(long, value_name = "BYTES")]
84 pub capacity: Option<u64>,
85 #[arg(long)]
86 pub json: bool,
87
88 #[command(flatten)]
89 pub lock: LockCliArgs,
90}
91
92#[derive(Args)]
94pub struct TicketsRevokeArgs {
95 #[arg(value_name = "FILE", value_parser = clap::value_parser!(PathBuf))]
96 pub file: PathBuf,
97 #[arg(long)]
98 pub json: bool,
99
100 #[command(flatten)]
101 pub lock: LockCliArgs,
102}
103
104#[derive(Args)]
106pub struct TicketsStatusArgs {
107 #[arg(value_name = "FILE", value_parser = clap::value_parser!(PathBuf))]
108 pub file: PathBuf,
109 #[arg(long)]
110 pub json: bool,
111}
112
113pub fn handle_tickets(config: &CliConfig, args: TicketsArgs) -> Result<()> {
118 match args.command {
119 TicketsCommand::List(status) => handle_ticket_status(status),
120 TicketsCommand::Issue(issue) => handle_ticket_issue(issue),
121 TicketsCommand::Revoke(revoke) => handle_ticket_revoke(revoke),
122 TicketsCommand::Sync(sync) => handle_ticket_sync(config, sync),
123 TicketsCommand::Apply(apply) => handle_ticket_apply(config, apply),
124 }
125}
126
127fn ticket_public_key(config: &CliConfig) -> Result<&ed25519_dalek::VerifyingKey> {
128 config
129 .ticket_pubkey
130 .as_ref()
131 .ok_or_else(|| anyhow!("MEMVID_TICKET_PUBKEY is not set"))
132}
133
134fn ticket_to_json(ticket: &memvid_core::TicketRef) -> serde_json::Value {
135 json!({
136 "issuer": ticket.issuer,
137 "seq_no": ticket.seq_no,
138 "expires_in_secs": ticket.expires_in_secs,
139 "capacity_bytes": ticket.capacity_bytes,
140 })
141}
142
143pub fn handle_ticket_status(args: TicketsStatusArgs) -> Result<()> {
144 let mem = open_read_only_mem(&args.file)?;
145 let ticket = mem.current_ticket();
146 if args.json {
147 println!(
148 "{}",
149 serde_json::to_string_pretty(&ticket_to_json(&ticket))?
150 );
151 } else {
152 println!("Ticket issuer: {}", ticket.issuer);
153 println!("Sequence: {}", ticket.seq_no);
154 println!("Expires in (secs): {}", ticket.expires_in_secs);
155 if ticket.capacity_bytes != 0 {
156 println!("Capacity bytes: {}", ticket.capacity_bytes);
157 } else {
158 println!("Capacity bytes: (tier default)");
159 }
160 }
161 Ok(())
162}
163
164pub fn handle_ticket_issue(args: TicketsIssueArgs) -> Result<()> {
165 let mut mem = Memvid::open(&args.file)?;
166 apply_lock_cli(&mut mem, &args.lock);
167 let mut ticket = Ticket::new(&args.issuer, args.seq);
168 if let Some(expires) = args.expires_in {
169 ticket = ticket.expires_in_secs(expires);
170 }
171 if let Some(capacity) = args.capacity {
172 ticket = ticket.capacity_bytes(capacity);
173 }
174 apply_ticket_with_warning(&mut mem, ticket)?;
175 let ticket = mem.current_ticket();
176 if args.json {
177 println!(
178 "{}",
179 serde_json::to_string_pretty(&ticket_to_json(&ticket))?
180 );
181 } else {
182 println!(
183 "Applied ticket seq={} issuer={}",
184 ticket.seq_no, ticket.issuer
185 );
186 }
187 Ok(())
188}
189
190pub fn handle_ticket_revoke(args: TicketsRevokeArgs) -> Result<()> {
191 let mut mem = Memvid::open(&args.file)?;
192 apply_lock_cli(&mut mem, &args.lock);
193 let current = mem.current_ticket();
194 let next_seq = current.seq_no.saturating_add(1).max(1);
195 let ticket = Ticket::new("", next_seq);
196 apply_ticket_with_warning(&mut mem, ticket)?;
197 let ticket = mem.current_ticket();
198 if args.json {
199 println!(
200 "{}",
201 serde_json::to_string_pretty(&ticket_to_json(&ticket))?
202 );
203 } else {
204 println!("Ticket cleared; seq advanced to {}", ticket.seq_no);
205 }
206 Ok(())
207}
208
209pub fn handle_ticket_sync(config: &CliConfig, args: TicketsSyncArgs) -> Result<()> {
210 let pubkey = ticket_public_key(config)?;
211 let response = api_fetch_ticket(config, &args.memory_id)?;
212
213 let file_path = std::fs::canonicalize(&args.file)
215 .with_context(|| format!("failed to get absolute path for {:?}", args.file))?;
216 let file_metadata = std::fs::metadata(&args.file)
217 .with_context(|| format!("failed to get file metadata for {:?}", args.file))?;
218 let file_size = file_metadata.len() as i64;
219 let file_name = args
220 .file
221 .file_name()
222 .and_then(|n| n.to_str())
223 .unwrap_or("unknown.mv2");
224 let machine_id = hostname::get()
225 .map(|h| h.to_string_lossy().to_string())
226 .unwrap_or_else(|_| "unknown".to_string());
227 let signature_bytes = BASE64_STANDARD
228 .decode(response.payload.signature.as_bytes())
229 .context("ticket signature is not valid base64")?;
230
231 let issuer = response.payload.issuer.clone();
233 let seq_no = response.payload.sequence;
234 let expires_in = response.payload.expires_in;
235 let capacity_bytes = response.payload.capacity_bytes;
236
237 verify_ticket_signature(
238 pubkey,
239 &args.memory_id,
240 &issuer,
241 seq_no,
242 expires_in,
243 capacity_bytes,
244 &signature_bytes,
245 )
246 .context("ticket signature verification failed")?;
247
248 let mut mem = Memvid::open(&args.file)?;
249 apply_lock_cli(&mut mem, &args.lock);
250
251 let current = mem.current_ticket();
253 if current.seq_no >= seq_no {
254 let capacity = mem.get_capacity();
256
257 let file_path_str = file_path.to_string_lossy();
259 let register_request = crate::api::RegisterFileRequest {
260 file_name,
261 file_path: &file_path_str,
262 file_size,
263 machine_id: &machine_id,
264 };
265 if let Err(e) = crate::api::register_file(config, &args.memory_id, ®ister_request) {
266 log::warn!("Failed to register file with control plane: {}", e);
267 }
268
269 if args.json {
270 let json = json!({
271 "issuer": current.issuer,
272 "seq_no": current.seq_no,
273 "expires_in": current.expires_in_secs,
274 "capacity_bytes": if current.capacity_bytes > 0 { Some(current.capacity_bytes) } else { None },
275 "memory_id": args.memory_id,
276 "already_bound": true,
277 });
278 println!("{}", serde_json::to_string_pretty(&json)?);
279 } else {
280 println!(
281 "Already bound to memory {} (seq={}, issuer={})",
282 args.memory_id, current.seq_no, current.issuer
283 );
284 println!(
285 "Current capacity: {:.2} GB",
286 capacity as f64 / 1024.0 / 1024.0 / 1024.0
287 );
288 }
289 return Ok(());
290 }
291
292 let mut ticket = Ticket::new(&issuer, seq_no).expires_in_secs(expires_in);
294 if let Some(capacity) = capacity_bytes {
295 ticket = ticket.capacity_bytes(capacity);
296 }
297
298 let binding = MemoryBinding {
300 memory_id: args.memory_id,
301 memory_name: issuer.clone(), bound_at: Utc::now(),
303 api_url: config.api_url.clone(),
304 };
305
306 mem.bind_memory(binding, ticket)
308 .context("failed to bind memory")?;
309 mem.commit()?;
310
311 let cache_entry = CachedTicket {
312 memory_id: args.memory_id,
313 issuer: issuer.clone(),
314 seq_no,
315 expires_in,
316 capacity_bytes,
317 signature: response.payload.signature.clone(),
318 };
319 store_cached_ticket(config, &cache_entry)?;
320
321 let capacity = mem.get_capacity();
322
323 let file_path_str = file_path.to_string_lossy();
325 let register_request = crate::api::RegisterFileRequest {
326 file_name,
327 file_path: &file_path_str,
328 file_size,
329 machine_id: &machine_id,
330 };
331 if let Err(e) = crate::api::register_file(config, &args.memory_id, ®ister_request) {
332 log::warn!("Failed to register file with control plane: {}", e);
334 }
335
336 if args.json {
337 let json = json!({
338 "issuer": issuer,
339 "seq_no": seq_no,
340 "expires_in": expires_in,
341 "capacity_bytes": capacity_bytes,
342 "memory_id": args.memory_id,
343 "request_id": response.request_id,
344 });
345 println!("{}", serde_json::to_string_pretty(&json)?);
346 } else {
347 println!(
348 "Bound to memory {} (seq={}, issuer={})",
349 args.memory_id, seq_no, issuer
350 );
351 println!(
352 "New capacity: {:.2} GB",
353 capacity as f64 / 1024.0 / 1024.0 / 1024.0
354 );
355 }
356 Ok(())
357}
358
359pub fn handle_ticket_apply(config: &CliConfig, args: TicketsApplyArgs) -> Result<()> {
360 if !args.from_api {
361 bail!("use --from-api to submit the ticket fetched from the API");
362 }
363 let cached = load_cached_ticket(config, &args.memory_id)
364 .context("no cached ticket available; run `tickets sync` first")?;
365 let request = ApiApplyTicketRequest {
366 issuer: &cached.issuer,
367 seq_no: cached.seq_no,
368 expires_in: cached.expires_in,
369 capacity_bytes: cached.capacity_bytes,
370 signature: &cached.signature,
371 };
372 let request_id = api_apply_ticket(config, &args.memory_id, &request)?;
373 if args.json {
374 let json = json!({
375 "memory_id": args.memory_id,
376 "seq_no": cached.seq_no,
377 "request_id": request_id,
378 });
379 println!("{}", serde_json::to_string_pretty(&json)?);
380 } else {
381 println!(
382 "Submitted ticket seq={} for memory {} (request {})",
383 cached.seq_no, args.memory_id, request_id
384 );
385 }
386 Ok(())
387}