Skip to main content

zixcel_github/
artifacts.rs

1use base64::{Engine as _, engine::general_purpose::STANDARD};
2use serde::{Deserialize, Serialize};
3use std::collections::{BTreeMap, BTreeSet};
4
5use crate::ConnectorError;
6
7const TEXT_SCHEMA: &str = "zixcel://github/text-artifact/v1";
8const INPUTS_SCHEMA: &str = "zixcel://github/workflow-inputs-artifact/v1";
9const SNAPSHOT_SCHEMA: &str = "zixcel://github/source-snapshot-artifact/v1";
10const MAX_ARTIFACT_BYTES: usize = 16 * 1024 * 1024;
11
12#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
13#[serde(deny_unknown_fields)]
14pub struct GitHubTextArtifact {
15    pub schema: String,
16    pub text: String,
17}
18
19#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
20#[serde(deny_unknown_fields)]
21pub struct GitHubWorkflowInputsArtifact {
22    pub schema: String,
23    pub inputs: BTreeMap<String, String>,
24}
25
26#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
27#[serde(deny_unknown_fields)]
28pub struct GitHubSnapshotArtifact {
29    pub schema: String,
30    pub branch: String,
31    pub message: String,
32    pub files: Vec<GitHubSnapshotFile>,
33}
34
35#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
36#[serde(deny_unknown_fields)]
37pub struct GitHubSnapshotFile {
38    pub path: String,
39    pub mode: GitHubFileMode,
40    pub content_base64: String,
41}
42
43#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)]
44pub enum GitHubFileMode {
45    #[serde(rename = "100644")]
46    Regular,
47    #[serde(rename = "100755")]
48    Executable,
49}
50
51impl GitHubFileMode {
52    #[must_use]
53    pub const fn as_str(self) -> &'static str {
54        match self {
55            Self::Regular => "100644",
56            Self::Executable => "100755",
57        }
58    }
59}
60
61/// Parses one bounded UTF-8 text artifact.
62///
63/// # Errors
64///
65/// Rejects unknown fields, invalid schema, or text larger than 64 KiB.
66pub fn parse_text_artifact(bytes: &[u8]) -> Result<GitHubTextArtifact, ConnectorError> {
67    let value: GitHubTextArtifact = decode(bytes)?;
68    if value.schema != TEXT_SCHEMA || value.text.len() > 65_536 {
69        return Err(invalid());
70    }
71    Ok(value)
72}
73
74/// Parses one bounded workflow-input map.
75///
76/// # Errors
77///
78/// Rejects invalid names, values, schema, count, or unknown fields.
79pub fn parse_workflow_inputs_artifact(
80    bytes: &[u8],
81) -> Result<GitHubWorkflowInputsArtifact, ConnectorError> {
82    let value: GitHubWorkflowInputsArtifact = decode(bytes)?;
83    if value.schema != INPUTS_SCHEMA
84        || value.inputs.len() > 64
85        || value.inputs.iter().any(|(key, item)| {
86            !token(key, 128)
87                || item.len() > 1024
88                || item
89                    .bytes()
90                    .any(|byte| byte.is_ascii_control() && !matches!(byte, b'\n' | b'\r' | b'\t'))
91        })
92    {
93        return Err(invalid());
94    }
95    Ok(value)
96}
97
98/// Parses a complete immutable source snapshot for the Git Data API.
99///
100/// # Errors
101///
102/// Rejects an invalid schema, branch, path, duplicate, mode, base64 payload,
103/// count, or aggregate decoded size.
104pub fn parse_snapshot_artifact(bytes: &[u8]) -> Result<GitHubSnapshotArtifact, ConnectorError> {
105    let value: GitHubSnapshotArtifact = decode(bytes)?;
106    if value.schema != SNAPSHOT_SCHEMA
107        || !git_branch(&value.branch)
108        || value.message.is_empty()
109        || value.message.len() > 512
110        || value.files.is_empty()
111        || value.files.len() > 4096
112    {
113        return Err(invalid());
114    }
115    let mut paths = BTreeSet::new();
116    let mut size = 0_usize;
117    for file in &value.files {
118        if !path(&file.path) || !paths.insert(file.path.as_str()) {
119            return Err(invalid());
120        }
121        let content = STANDARD
122            .decode(&file.content_base64)
123            .map_err(|_| invalid())?;
124        size = size.checked_add(content.len()).ok_or_else(invalid)?;
125        if content.len() > 1_048_576 || size > 8 * 1024 * 1024 {
126            return Err(invalid());
127        }
128    }
129    Ok(value)
130}
131
132fn decode<T: for<'de> Deserialize<'de>>(bytes: &[u8]) -> Result<T, ConnectorError> {
133    if bytes.is_empty() || bytes.len() > MAX_ARTIFACT_BYTES {
134        return Err(invalid());
135    }
136    serde_json::from_slice(bytes).map_err(|_| invalid())
137}
138
139fn token(value: &str, maximum: usize) -> bool {
140    !value.is_empty()
141        && value.len() <= maximum
142        && value.trim() == value
143        && !value.bytes().any(|byte| byte.is_ascii_control())
144}
145
146fn path(value: &str) -> bool {
147    token(value, 512)
148        && !value.contains('\\')
149        && !value
150            .split('/')
151            .any(|part| part.eq_ignore_ascii_case(".git"))
152        && !value.starts_with('/')
153        && !value.ends_with('/')
154        && !value
155            .split('/')
156            .any(|item| item.is_empty() || matches!(item, "." | ".."))
157}
158
159fn invalid() -> ConnectorError {
160    ConnectorError::new("artifact", "invalid GitHub artifact")
161}
162
163pub(crate) fn git_branch(value: &str) -> bool {
164    token(value, 128)
165        && !value.starts_with('-')
166        && !value.starts_with('/')
167        && !value.ends_with('/')
168        && !value.ends_with('.')
169        && !value.contains("..")
170        && !value.contains("@{")
171        && !value.contains("//")
172        && !value.bytes().any(|b| b" ~^:?*[\\".contains(&b))
173        && !value
174            .split('/')
175            .any(|p| p.starts_with('.') || p.ends_with(".lock"))
176}