1use std::path::PathBuf;
4use std::str::FromStr;
5
6use anyhow::{anyhow, bail, Context, Result};
7use base64::{engine::general_purpose::STANDARD as BASE64_STANDARD, Engine};
8use chrono::Utc;
9use clap::{ArgAction, Args, Subcommand};
10use memvid_core::types::MemoryBinding;
11use memvid_core::{verify_ticket_signature, Memvid, Ticket};
12use serde_json::json;
13use uuid::Uuid;
14
15#[derive(Debug, Clone, Copy)]
17pub struct MemoryId(Uuid);
18
19impl serde::Serialize for MemoryId {
20 fn serialize<S>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error>
21 where
22 S: serde::Serializer,
23 {
24 self.0.serialize(serializer)
25 }
26}
27
28impl MemoryId {
29 pub fn as_uuid(&self) -> &Uuid {
30 &self.0
31 }
32}
33
34impl std::ops::Deref for MemoryId {
35 type Target = Uuid;
36 fn deref(&self) -> &Self::Target {
37 &self.0
38 }
39}
40
41impl std::fmt::Display for MemoryId {
42 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
43 write!(f, "{}", self.0)
44 }
45}
46
47impl FromStr for MemoryId {
48 type Err = String;
49
50 fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
51 if let Ok(uuid) = Uuid::parse_str(s) {
53 return Ok(MemoryId(uuid));
54 }
55
56 if s.len() == 24 && s.chars().all(|c| c.is_ascii_hexdigit()) {
58 let padded = format!("{}00000000", s);
60 if let Ok(uuid) = Uuid::parse_str(&padded) {
61 return Ok(MemoryId(uuid));
62 }
63 }
64
65 Err(format!(
66 "invalid memory ID '{}': expected UUID (32 chars) or ObjectId (24 chars)",
67 s
68 ))
69 }
70}
71
72use crate::api::{
73 apply_ticket as api_apply_ticket, fetch_ticket as api_fetch_ticket,
74 ApplyTicketRequest as ApiApplyTicketRequest,
75};
76use crate::commands::{apply_ticket_with_warning, LockCliArgs};
77use crate::config::CliConfig;
78use crate::ticket_cache::{load as load_cached_ticket, store as store_cached_ticket, CachedTicket};
79use crate::utils::{apply_lock_cli, open_read_only_mem};
80
81#[derive(Subcommand)]
83pub enum TicketsCommand {
84 List(TicketsStatusArgs),
86 Issue(TicketsIssueArgs),
88 Revoke(TicketsRevokeArgs),
90 Sync(TicketsSyncArgs),
92 Apply(TicketsApplyArgs),
94}
95
96#[derive(Args)]
98pub struct TicketsArgs {
99 #[command(subcommand)]
100 pub command: TicketsCommand,
101}
102
103#[derive(Args)]
105pub struct TicketsSyncArgs {
106 #[arg(value_name = "FILE", value_parser = clap::value_parser!(PathBuf))]
107 pub file: PathBuf,
108 #[arg(long = "memory-id", value_name = "ID", help = "Memory ID (UUID or 24-char ObjectId)")]
109 pub memory_id: MemoryId,
110 #[arg(long)]
111 pub json: bool,
112
113 #[command(flatten)]
114 pub lock: LockCliArgs,
115}
116
117#[derive(Args)]
119pub struct TicketsApplyArgs {
120 #[arg(value_name = "FILE", value_parser = clap::value_parser!(PathBuf))]
121 pub file: PathBuf,
122 #[arg(long = "memory-id", value_name = "ID", help = "Memory ID (UUID or 24-char ObjectId)")]
123 pub memory_id: MemoryId,
124 #[arg(long = "from-api", action = ArgAction::SetTrue)]
125 pub from_api: bool,
126 #[arg(long)]
127 pub json: bool,
128}
129
130#[derive(Args)]
132pub struct TicketsIssueArgs {
133 #[arg(value_name = "FILE", value_parser = clap::value_parser!(PathBuf))]
134 pub file: PathBuf,
135 #[arg(long)]
136 pub issuer: String,
137 #[arg(long, value_name = "SEQ")]
138 pub seq: i64,
139 #[arg(long = "expires-in", value_name = "SECS")]
140 pub expires_in: Option<u64>,
141 #[arg(long, value_name = "BYTES")]
142 pub capacity: Option<u64>,
143 #[arg(long)]
144 pub json: bool,
145
146 #[command(flatten)]
147 pub lock: LockCliArgs,
148}
149
150#[derive(Args)]
152pub struct TicketsRevokeArgs {
153 #[arg(value_name = "FILE", value_parser = clap::value_parser!(PathBuf))]
154 pub file: PathBuf,
155 #[arg(long)]
156 pub json: bool,
157
158 #[command(flatten)]
159 pub lock: LockCliArgs,
160}
161
162#[derive(Args)]
164pub struct TicketsStatusArgs {
165 #[arg(value_name = "FILE", value_parser = clap::value_parser!(PathBuf))]
166 pub file: PathBuf,
167 #[arg(long)]
168 pub json: bool,
169}
170
171pub fn handle_tickets(config: &CliConfig, args: TicketsArgs) -> Result<()> {
176 match args.command {
177 TicketsCommand::List(status) => handle_ticket_status(status),
178 TicketsCommand::Issue(issue) => handle_ticket_issue(issue),
179 TicketsCommand::Revoke(revoke) => handle_ticket_revoke(revoke),
180 TicketsCommand::Sync(sync) => handle_ticket_sync(config, sync),
181 TicketsCommand::Apply(apply) => handle_ticket_apply(config, apply),
182 }
183}
184
185fn ticket_public_key(config: &CliConfig) -> Result<&ed25519_dalek::VerifyingKey> {
186 config
187 .ticket_pubkey
188 .as_ref()
189 .ok_or_else(|| anyhow!("MEMVID_TICKET_PUBKEY is not set"))
190}
191
192fn ticket_to_json(ticket: &memvid_core::TicketRef) -> serde_json::Value {
193 json!({
194 "issuer": ticket.issuer,
195 "seq_no": ticket.seq_no,
196 "expires_in_secs": ticket.expires_in_secs,
197 "capacity_bytes": ticket.capacity_bytes,
198 })
199}
200
201pub fn handle_ticket_status(args: TicketsStatusArgs) -> Result<()> {
202 let mem = open_read_only_mem(&args.file)?;
203 let ticket = mem.current_ticket();
204 if args.json {
205 println!(
206 "{}",
207 serde_json::to_string_pretty(&ticket_to_json(&ticket))?
208 );
209 } else {
210 println!("Ticket issuer: {}", ticket.issuer);
211 println!("Sequence: {}", ticket.seq_no);
212 println!("Expires in (secs): {}", ticket.expires_in_secs);
213 if ticket.capacity_bytes != 0 {
214 println!("Capacity bytes: {}", ticket.capacity_bytes);
215 } else {
216 println!("Capacity bytes: (tier default)");
217 }
218 }
219 Ok(())
220}
221
222pub fn handle_ticket_issue(args: TicketsIssueArgs) -> Result<()> {
223 let mut mem = Memvid::open(&args.file)?;
224 apply_lock_cli(&mut mem, &args.lock);
225 let mut ticket = Ticket::new(&args.issuer, args.seq);
226 if let Some(expires) = args.expires_in {
227 ticket = ticket.expires_in_secs(expires);
228 }
229 if let Some(capacity) = args.capacity {
230 ticket = ticket.capacity_bytes(capacity);
231 }
232 apply_ticket_with_warning(&mut mem, ticket)?;
233 let ticket = mem.current_ticket();
234 if args.json {
235 println!(
236 "{}",
237 serde_json::to_string_pretty(&ticket_to_json(&ticket))?
238 );
239 } else {
240 println!(
241 "Applied ticket seq={} issuer={}",
242 ticket.seq_no, ticket.issuer
243 );
244 }
245 Ok(())
246}
247
248pub fn handle_ticket_revoke(args: TicketsRevokeArgs) -> Result<()> {
249 let mut mem = Memvid::open(&args.file)?;
250 apply_lock_cli(&mut mem, &args.lock);
251 let current = mem.current_ticket();
252 let next_seq = current.seq_no.saturating_add(1).max(1);
253 let ticket = Ticket::new("", next_seq);
254 apply_ticket_with_warning(&mut mem, ticket)?;
255 let ticket = mem.current_ticket();
256 if args.json {
257 println!(
258 "{}",
259 serde_json::to_string_pretty(&ticket_to_json(&ticket))?
260 );
261 } else {
262 println!("Ticket cleared; seq advanced to {}", ticket.seq_no);
263 }
264 Ok(())
265}
266
267pub fn handle_ticket_sync(config: &CliConfig, args: TicketsSyncArgs) -> Result<()> {
268 let pubkey = ticket_public_key(config)?;
269 let response = api_fetch_ticket(config, &args.memory_id)?;
270
271 let file_path = std::fs::canonicalize(&args.file)
273 .with_context(|| format!("failed to get absolute path for {:?}", args.file))?;
274 let file_metadata = std::fs::metadata(&args.file)
275 .with_context(|| format!("failed to get file metadata for {:?}", args.file))?;
276 let file_size = file_metadata.len() as i64;
277 let file_name = args
278 .file
279 .file_name()
280 .and_then(|n| n.to_str())
281 .unwrap_or("unknown.mv2");
282 let machine_id = hostname::get()
283 .map(|h| h.to_string_lossy().to_string())
284 .unwrap_or_else(|_| "unknown".to_string());
285 let signature_bytes = BASE64_STANDARD
286 .decode(response.payload.signature.as_bytes())
287 .context("ticket signature is not valid base64")?;
288
289 let issuer = response.payload.issuer.clone();
291 let seq_no = response.payload.sequence;
292 let expires_in = response.payload.expires_in;
293 let capacity_bytes = response.payload.capacity_bytes;
294
295 verify_ticket_signature(
296 pubkey,
297 &args.memory_id,
298 &issuer,
299 seq_no,
300 expires_in,
301 capacity_bytes,
302 &signature_bytes,
303 )
304 .context("ticket signature verification failed")?;
305
306 let mut mem = Memvid::open(&args.file)?;
307 apply_lock_cli(&mut mem, &args.lock);
308
309 let current = mem.current_ticket();
311 if current.seq_no >= seq_no {
312 let capacity = mem.get_capacity();
314
315 let file_path_str = file_path.to_string_lossy();
317 let register_request = crate::api::RegisterFileRequest {
318 file_name,
319 file_path: &file_path_str,
320 file_size,
321 machine_id: &machine_id,
322 };
323 if let Some(api_key) = config.api_key.as_deref() {
324 if let Err(e) = crate::api::register_file(config, &args.memory_id, ®ister_request, api_key) {
325 log::warn!("Failed to register file with dashboard: {}", e);
326 }
327 }
328
329 if args.json {
330 let json = json!({
331 "issuer": current.issuer,
332 "seq_no": current.seq_no,
333 "expires_in": current.expires_in_secs,
334 "capacity_bytes": if current.capacity_bytes > 0 { Some(current.capacity_bytes) } else { None },
335 "memory_id": args.memory_id,
336 "already_bound": true,
337 });
338 println!("{}", serde_json::to_string_pretty(&json)?);
339 } else {
340 println!(
341 "Already bound to memory {} (seq={}, issuer={})",
342 args.memory_id, current.seq_no, current.issuer
343 );
344 println!(
345 "Current capacity: {:.2} GB",
346 capacity as f64 / 1024.0 / 1024.0 / 1024.0
347 );
348 }
349 return Ok(());
350 }
351
352 let file_path_str = file_path.to_string_lossy();
355 let register_request = crate::api::RegisterFileRequest {
356 file_name,
357 file_path: &file_path_str,
358 file_size,
359 machine_id: &machine_id,
360 };
361 if let Some(api_key) = config.api_key.as_deref() {
362 crate::api::register_file(config, &args.memory_id, ®ister_request, api_key)
363 .context("failed to register file with dashboard - this memory may already be bound to another file")?;
364 } else {
365 bail!("API key required for binding. Set MEMVID_API_KEY.");
366 }
367
368 let mut ticket = Ticket::new(&issuer, seq_no).expires_in_secs(expires_in);
370 if let Some(capacity) = capacity_bytes {
371 ticket = ticket.capacity_bytes(capacity);
372 }
373
374 let binding = MemoryBinding {
376 memory_id: *args.memory_id,
377 memory_name: issuer.clone(), bound_at: Utc::now(),
379 api_url: config.api_url.clone(),
380 };
381
382 mem.bind_memory(binding, ticket)
384 .context("failed to bind memory")?;
385 mem.commit()?;
386
387 let cache_entry = CachedTicket {
388 memory_id: *args.memory_id,
389 issuer: issuer.clone(),
390 seq_no,
391 expires_in,
392 capacity_bytes,
393 signature: response.payload.signature.clone(),
394 };
395 store_cached_ticket(config, &cache_entry)?;
396
397 let capacity = mem.get_capacity();
398
399 if args.json {
400 let json = json!({
401 "issuer": issuer,
402 "seq_no": seq_no,
403 "expires_in": expires_in,
404 "capacity_bytes": capacity_bytes,
405 "memory_id": args.memory_id,
406 "request_id": response.request_id,
407 });
408 println!("{}", serde_json::to_string_pretty(&json)?);
409 } else {
410 println!(
411 "Bound to memory {} (seq={}, issuer={})",
412 args.memory_id, seq_no, issuer
413 );
414 println!(
415 "New capacity: {:.2} GB",
416 capacity as f64 / 1024.0 / 1024.0 / 1024.0
417 );
418 }
419 Ok(())
420}
421
422pub fn handle_ticket_apply(config: &CliConfig, args: TicketsApplyArgs) -> Result<()> {
423 if !args.from_api {
424 bail!("use --from-api to submit the ticket fetched from the API");
425 }
426 let cached = load_cached_ticket(config, &args.memory_id)
427 .context("no cached ticket available; run `tickets sync` first")?;
428 let request = ApiApplyTicketRequest {
429 issuer: &cached.issuer,
430 seq_no: cached.seq_no,
431 expires_in: cached.expires_in,
432 capacity_bytes: cached.capacity_bytes,
433 signature: &cached.signature,
434 };
435 let request_id = api_apply_ticket(config, &args.memory_id, &request)?;
436 if args.json {
437 let json = json!({
438 "memory_id": args.memory_id,
439 "seq_no": cached.seq_no,
440 "request_id": request_id,
441 });
442 println!("{}", serde_json::to_string_pretty(&json)?);
443 } else {
444 println!(
445 "Submitted ticket seq={} for memory {} (request {})",
446 cached.seq_no, args.memory_id, request_id
447 );
448 }
449 Ok(())
450}