Skip to main content

mj_controller/server/api/
files.rs

1use super::*;
2
3/// A unified diff of everything the session changed.
4pub(super) async fn diff(
5    State(state): State<ServerState>,
6    Path(session_id): Path<String>,
7    Query(options): Query<DiffOptions>,
8) -> Result<Response, ApiFailure> {
9    let backend = backend(&state)?.clone();
10    let json = options.json;
11    let diff = backend.diff(session_id, options).await?;
12    if json {
13        let details: mj_checkpoint::archive::SessionDiff =
14            serde_json::from_str(&diff).context("decode session diff metadata")?;
15        return Ok(Json(details).into_response());
16    }
17    Ok(([(CONTENT_TYPE, "text/x-diff; charset=utf-8")], diff).into_response())
18}
19
20/// Reject a file path this API can never resolve, before it costs a round trip
21/// to the target.
22///
23/// An absolute path is always a mistake worth naming here. A `..` is not: it is
24/// how a multi-repo bundle names a sibling repository, now that a path resolves
25/// in the directory the agent runs in rather than at the workspace root
26/// (#1079). How far `..` may climb depends on the session's layout, which only
27/// the daemon holds, so that is refused there and answers 409.
28fn validate_session_file_path(path: &std::path::Path) -> Result<(), ApiFailure> {
29    if path.as_os_str().is_empty()
30        || path.is_absolute()
31        || path
32            .components()
33            .any(|component| matches!(component, Component::Prefix(_) | Component::RootDir))
34    {
35        return Err(ApiFailure::bad_request(
36            "path must be relative to the directory the session's agent runs in",
37        ));
38    }
39    Ok(())
40}
41
42/// One file from the session's workspace, as bytes.
43///
44/// The path is checked here as well as on the target: a caller that spells an
45/// absolute or escaping path has made a mistake worth naming, and there is no
46/// reason to spend a round trip to the target discovering it.
47#[derive(Debug, Clone, Serialize, Deserialize)]
48pub struct WriteFileQuery {
49    pub path: PathBuf,
50    #[serde(default)]
51    pub overwrite: bool,
52}
53
54#[derive(Debug, Clone, Serialize, Deserialize)]
55pub struct WriteFileResponse {
56    pub path: PathBuf,
57    pub bytes: usize,
58}
59
60pub(super) async fn write_file(
61    State(state): State<ServerState>,
62    Path(session_id): Path<String>,
63    Query(query): Query<WriteFileQuery>,
64    bytes: axum::body::Bytes,
65) -> Result<Json<WriteFileResponse>, ApiFailure> {
66    validate_session_file_path(&query.path)?;
67    {
68        let snapshot = state.snapshot_rx.borrow();
69        let session = require_session_record(&snapshot, &session_id)?;
70        if !session.is_idle || session.lifecycle != ViewerLifecycleCategory::Live {
71            return Err(ApiFailure::conflict(
72                "session must be live and idle for file injection",
73            ));
74        }
75    }
76    let count = bytes.len();
77    backend(&state)?
78        .write_file(
79            session_id,
80            query.path.clone(),
81            bytes.to_vec(),
82            query.overwrite,
83        )
84        .await?;
85    Ok(Json(WriteFileResponse {
86        path: query.path,
87        bytes: count,
88    }))
89}
90
91pub(super) async fn elicitations(
92    State(state): State<ServerState>,
93    Path(session_id): Path<String>,
94) -> Result<Json<Vec<mj_core::elicitation::ElicitationRequest>>, ApiFailure> {
95    let snapshot = state.snapshot_rx.borrow();
96    Ok(Json(
97        require_session_record(&snapshot, &session_id)?
98            .pending_elicitations
99            .clone(),
100    ))
101}
102
103pub(super) async fn respond_elicitation(
104    State(state): State<ServerState>,
105    Path((session_id, elicitation_id)): Path<(String, String)>,
106    Json(response): Json<mj_core::elicitation::ElicitationResponse>,
107) -> Result<StatusCode, ApiFailure> {
108    send_action(
109        &state,
110        ControllerAction::RespondElicitation {
111            session_id,
112            elicitation_id,
113            response,
114        },
115    )
116    .await
117}
118
119pub(super) async fn read_file(
120    State(state): State<ServerState>,
121    Path(session_id): Path<String>,
122    Query(query): Query<FileQuery>,
123) -> Result<Response, ApiFailure> {
124    let backend = backend(&state)?.clone();
125    let path = PathBuf::from(query.path.trim());
126    validate_session_file_path(&path)?;
127    let bytes = backend.read_file(session_id, path).await?;
128    Ok(([(CONTENT_TYPE, "application/octet-stream")], bytes).into_response())
129}
130
131/// Get the session's work out, in whichever form the caller asked for.
132pub(super) async fn export(
133    State(state): State<ServerState>,
134    Path(session_id): Path<String>,
135    Json(request): Json<ExportRequest>,
136) -> Result<Response, ApiFailure> {
137    let backend = backend(&state)?.clone();
138    match request.kind {
139        ExportKind::Patch => {
140            let diff = backend.diff(session_id, DiffOptions::default()).await?;
141            Ok(([(CONTENT_TYPE, "text/x-diff; charset=utf-8")], diff).into_response())
142        }
143        ExportKind::Branch => {
144            let branch = request
145                .branch
146                .as_deref()
147                .map(str::trim)
148                .filter(|branch| !branch.is_empty())
149                .ok_or_else(|| ApiFailure::bad_request("a branch export needs a branch name"))?
150                .to_owned();
151            let pushed = backend.push_branch(session_id, branch).await?;
152            Ok(Json(pushed).into_response())
153        }
154        ExportKind::Bundle => {
155            let bundle = backend.bundle(session_id.clone()).await?;
156            // The filename reaches a header, so keep it to characters that
157            // cannot end the quoted string or split the response.
158            let filename: String = format!("{session_id}-{}.bundle", bundle.repository)
159                .chars()
160                .map(|character| match character {
161                    'A'..='Z' | 'a'..='z' | '0'..='9' | '.' | '-' | '_' => character,
162                    _ => '-',
163                })
164                .collect();
165            Ok((
166                [
167                    (CONTENT_TYPE, "application/octet-stream".to_owned()),
168                    (
169                        CONTENT_DISPOSITION,
170                        format!("attachment; filename=\"{filename}\""),
171                    ),
172                ],
173                bundle.bytes,
174            )
175                .into_response())
176        }
177    }
178}