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