Skip to content

Commit efa3e28

Browse files
committed
feat: implement file operations and shutdown handler in guest agent
1 parent f11c64e commit efa3e28

8 files changed

Lines changed: 454 additions & 7 deletions

File tree

Cargo.lock

Lines changed: 72 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

crates/sandchest-agent/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ tokio-stream = "0.1"
1414
pin-project-lite = "0.2"
1515
libc = "0.2"
1616
nix = { version = "0.29", features = ["term", "process", "fs"] }
17+
sha2 = "0.10"
1718

1819
[target.'cfg(target_os = "linux")'.dependencies]
1920
tokio-vsock = { version = "0.4", optional = true }
Lines changed: 237 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,237 @@
1+
use std::path::Path;
2+
3+
use sha2::{Digest, Sha256};
4+
use tokio::io::AsyncReadExt;
5+
use tonic::{Status, Streaming};
6+
7+
use crate::proto::{FileChunk, FileInfo, GetFileRequest, ListFilesRequest, ListFilesResponse, PutFileResponse};
8+
9+
const GET_FILE_CHUNK_SIZE: usize = 64 * 1024; // 64 KB
10+
11+
pub async fn put_file(mut stream: Streaming<FileChunk>) -> Result<PutFileResponse, Status> {
12+
let first = stream
13+
.message()
14+
.await?
15+
.ok_or_else(|| Status::invalid_argument("empty file stream"))?;
16+
17+
if first.path.is_empty() {
18+
return Err(Status::invalid_argument("first chunk must include path"));
19+
}
20+
21+
let dest = Path::new(&first.path);
22+
23+
// Create parent directories
24+
if let Some(parent) = dest.parent() {
25+
tokio::fs::create_dir_all(parent)
26+
.await
27+
.map_err(|e| Status::internal(format!("failed to create directories: {e}")))?;
28+
}
29+
30+
let mut file = tokio::fs::File::create(dest)
31+
.await
32+
.map_err(|e| Status::internal(format!("failed to create file: {e}")))?;
33+
34+
let mut hasher = Sha256::new();
35+
let mut bytes_written: u64 = 0;
36+
37+
// Write first chunk
38+
if !first.data.is_empty() {
39+
tokio::io::AsyncWriteExt::write_all(&mut file, &first.data)
40+
.await
41+
.map_err(|e| Status::internal(format!("write failed: {e}")))?;
42+
hasher.update(&first.data);
43+
bytes_written += first.data.len() as u64;
44+
}
45+
46+
if !first.done {
47+
// Read remaining chunks
48+
while let Some(chunk) = stream.message().await? {
49+
if !chunk.data.is_empty() {
50+
tokio::io::AsyncWriteExt::write_all(&mut file, &chunk.data)
51+
.await
52+
.map_err(|e| Status::internal(format!("write failed: {e}")))?;
53+
hasher.update(&chunk.data);
54+
bytes_written += chunk.data.len() as u64;
55+
}
56+
if chunk.done {
57+
break;
58+
}
59+
}
60+
}
61+
62+
tokio::io::AsyncWriteExt::flush(&mut file)
63+
.await
64+
.map_err(|e| Status::internal(format!("flush failed: {e}")))?;
65+
66+
let _checksum = format!("{:x}", hasher.finalize());
67+
68+
Ok(PutFileResponse { bytes_written })
69+
}
70+
71+
pub fn spawn_get_file(
72+
request: GetFileRequest,
73+
) -> tokio_stream::wrappers::ReceiverStream<Result<FileChunk, Status>> {
74+
let (tx, rx) = tokio::sync::mpsc::channel(32);
75+
76+
tokio::spawn(async move {
77+
if let Err(e) = run_get_file(request, &tx).await {
78+
let _ = tx.send(Err(e)).await;
79+
}
80+
});
81+
82+
tokio_stream::wrappers::ReceiverStream::new(rx)
83+
}
84+
85+
async fn run_get_file(
86+
request: GetFileRequest,
87+
tx: &tokio::sync::mpsc::Sender<Result<FileChunk, Status>>,
88+
) -> Result<(), Status> {
89+
let path = Path::new(&request.path);
90+
91+
if !path.exists() {
92+
return Err(Status::not_found(format!(
93+
"file not found: {}",
94+
request.path
95+
)));
96+
}
97+
98+
let metadata = tokio::fs::metadata(path)
99+
.await
100+
.map_err(|e| Status::internal(format!("failed to read metadata: {e}")))?;
101+
102+
if metadata.is_dir() {
103+
return Err(Status::invalid_argument(format!(
104+
"path is a directory: {}",
105+
request.path
106+
)));
107+
}
108+
109+
let mut file = tokio::fs::File::open(path)
110+
.await
111+
.map_err(|e| Status::internal(format!("failed to open file: {e}")))?;
112+
113+
let mut buf = vec![0u8; GET_FILE_CHUNK_SIZE];
114+
let mut offset: u64 = 0;
115+
116+
loop {
117+
let n = file
118+
.read(&mut buf)
119+
.await
120+
.map_err(|e| Status::internal(format!("read failed: {e}")))?;
121+
122+
if n == 0 {
123+
// Send final empty chunk with done=true
124+
let chunk = FileChunk {
125+
path: request.path.clone(),
126+
data: Vec::new(),
127+
offset,
128+
done: true,
129+
};
130+
tx.send(Ok(chunk))
131+
.await
132+
.map_err(|_| Status::cancelled("client disconnected"))?;
133+
break;
134+
}
135+
136+
let done = n < GET_FILE_CHUNK_SIZE;
137+
let chunk = FileChunk {
138+
path: request.path.clone(),
139+
data: buf[..n].to_vec(),
140+
offset,
141+
done,
142+
};
143+
offset += n as u64;
144+
145+
tx.send(Ok(chunk))
146+
.await
147+
.map_err(|_| Status::cancelled("client disconnected"))?;
148+
149+
if done {
150+
break;
151+
}
152+
}
153+
154+
Ok(())
155+
}
156+
157+
pub async fn list_files(request: ListFilesRequest) -> Result<ListFilesResponse, Status> {
158+
let path = Path::new(&request.path);
159+
160+
if !path.exists() {
161+
return Err(Status::not_found(format!(
162+
"path not found: {}",
163+
request.path
164+
)));
165+
}
166+
167+
if !path.is_dir() {
168+
return Err(Status::invalid_argument(format!(
169+
"path is not a directory: {}",
170+
request.path
171+
)));
172+
}
173+
174+
let mut entries = tokio::fs::read_dir(path)
175+
.await
176+
.map_err(|e| Status::internal(format!("failed to read directory: {e}")))?;
177+
178+
let mut files = Vec::new();
179+
180+
while let Some(entry) = entries
181+
.next_entry()
182+
.await
183+
.map_err(|e| Status::internal(format!("failed to read entry: {e}")))?
184+
{
185+
let metadata = match entry.metadata().await {
186+
Ok(m) => m,
187+
Err(_) => continue, // skip entries we can't stat
188+
};
189+
190+
let modified_at = metadata
191+
.modified()
192+
.ok()
193+
.and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
194+
.map(|d| d.as_secs() as i64)
195+
.unwrap_or(0);
196+
197+
files.push(FileInfo {
198+
path: entry.path().to_string_lossy().to_string(),
199+
size: metadata.len(),
200+
is_dir: metadata.is_dir(),
201+
modified_at,
202+
});
203+
}
204+
205+
// Sort by name for deterministic output
206+
files.sort_by(|a, b| a.path.cmp(&b.path));
207+
208+
Ok(ListFilesResponse { files })
209+
}
210+
211+
#[cfg(test)]
212+
mod tests {
213+
use super::*;
214+
215+
#[tokio::test]
216+
async fn test_list_files_nonexistent() {
217+
let result = list_files(ListFilesRequest {
218+
path: "/nonexistent/path/that/does/not/exist".to_string(),
219+
})
220+
.await;
221+
assert!(result.is_err());
222+
let status = result.unwrap_err();
223+
assert_eq!(status.code(), tonic::Code::NotFound);
224+
}
225+
226+
#[tokio::test]
227+
async fn test_list_files_on_file() {
228+
// /etc/hosts is a file, not a directory
229+
let result = list_files(ListFilesRequest {
230+
path: "/etc/hosts".to_string(),
231+
})
232+
.await;
233+
// On macOS/Linux this should be InvalidArgument (not a directory)
234+
// or NotFound if it doesn't exist
235+
assert!(result.is_err());
236+
}
237+
}

crates/sandchest-agent/src/main.rs

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,10 @@
11
mod exec;
2+
mod files;
23
mod proc;
34
mod service;
45
mod session;
6+
mod shutdown;
7+
mod snapshot;
58
mod vsock;
69

710
pub mod proto {
@@ -19,6 +22,14 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
1922
)
2023
.init();
2124

25+
// Check for snapshot restore before anything else
26+
if snapshot::detect_snapshot_restore() {
27+
snapshot::handle_restore();
28+
}
29+
30+
// Start periodic heartbeat file writer
31+
snapshot::start_heartbeat_writer();
32+
2233
let tcp_port: u16 = std::env::var("SANDCHEST_AGENT_TCP_PORT")
2334
.ok()
2435
.and_then(|s| s.parse().ok())

0 commit comments

Comments
 (0)