blob: e6948c545c5fde1c74b558d9e3b929d478c3eabc [file]
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
/// Material committed atomically with a canonical fact, not a best-effort file reference.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ArtifactEvidence {
pub id: String,
pub bytes: u64,
pub digest: String,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ArtifactKind {
File,
Diff,
Html,
Image,
Pdf,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ArtifactSource {
ToolResult,
ToolResultProjection,
ToolResultArchive,
SubagentWriteback,
UserUpload,
SessionEffect,
}
impl ArtifactSource {
pub fn user_deletable(self) -> bool {
matches!(self, Self::ToolResult | Self::UserUpload)
}
}
/// Canonical descriptor. The payload is owned by storage, never a renderer path.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct Artifact {
pub id: String,
pub session_id: String,
pub turn_id: String,
pub created_at: u64,
pub name: String,
pub kind: ArtifactKind,
pub size_bytes: u64,
#[serde(
default,
deserialize_with = "optional_text",
skip_serializing_if = "Option::is_none"
)]
pub mime_type: Option<String>,
pub source: ArtifactSource,
#[serde(
default,
deserialize_with = "optional_text",
skip_serializing_if = "Option::is_none"
)]
pub summary: Option<String>,
}
impl Artifact {
pub fn validate_attachment(
&self,
attachment: &crate::attachment::AttachmentRef,
) -> Result<(), &'static str> {
use crate::attachment::AttachmentKind;
if self.name != attachment.name
|| self.mime_type.as_deref() != Some(attachment.mime_type.as_str())
|| self.size_bytes != attachment.bytes
{
return Err("Attachment metadata does not match its canonical Artifact");
}
let kind = AttachmentKind::from_metadata(&attachment.mime_type, &self.name);
if attachment.kind != kind
|| (kind == AttachmentKind::Image) != (self.kind == ArtifactKind::Image)
|| (kind == AttachmentKind::Pdf) != (self.kind == ArtifactKind::Pdf)
{
return Err("Attachment kind does not match its canonical Artifact");
}
Ok(())
}
pub fn validate(&self) -> Result<(), &'static str> {
crate::interaction::entity_id(&self.id)?;
crate::interaction::entity_id(&self.session_id)?;
if self.turn_id.is_empty()
|| self.turn_id.bytes().any(|b| b < 32 || b == 127)
|| self.created_at > 9_007_199_254_740_991
|| self.size_bytes > 9_007_199_254_740_991
|| self.name.is_empty()
|| self.mime_type.as_ref().is_some_and(String::is_empty)
|| self.summary.as_ref().is_some_and(String::is_empty)
{
return Err("Invalid artifact descriptor");
}
Ok(())
}
}
fn optional_text<'de, D: serde::Deserializer<'de>>(
deserializer: D,
) -> Result<Option<String>, D::Error> {
String::deserialize(deserializer).map(Some)
}
/// Stable logical identity makes an acknowledged or ambiguous upload retryable
/// without assigning it a different destination or overwriting another artifact.
pub fn upload_artifact_id(session_id: &str, upload_id: &str) -> String {
let mut digest = Sha256::new();
digest.update(session_id);
digest.update([0]);
digest.update(upload_id);
format!("attachment-{}", &hex::encode(digest.finalize())[..32])
}
pub fn content_digest(bytes: &[u8]) -> String {
format!("sha256:{}", hex::encode(Sha256::digest(bytes)))
}
pub fn workspace_patch_id(session: &str, turn: &str) -> String {
let identity = serde_json::to_vec(&(session, turn)).expect("string identity serializes");
format!("worktree-{}", hex::encode(Sha256::digest(identity)))
}
pub fn normalize_name(name: &str) -> String {
let mut cleaned = String::new();
let mut whitespace = false;
for character in name.trim_matches(js_whitespace).chars() {
if js_whitespace(character) {
if !whitespace {
cleaned.push(' ');
}
whitespace = true;
} else {
cleaned.push(if "\\/:*?\"<>|\0".contains(character) {
'-'
} else {
character
});
whitespace = false;
}
}
let cleaned = cleaned.trim_matches([' ', '.', '-']);
let result = cleaned;
if result.is_empty() {
"artifact".into()
} else {
result.into()
}
}
fn js_whitespace(character: char) -> bool {
matches!(character, '\u{0009}'..='\u{000d}' | '\u{0020}' | '\u{00a0}' |
'\u{1680}' | '\u{2000}'..='\u{200a}' | '\u{2028}' | '\u{2029}' |
'\u{202f}' | '\u{205f}' | '\u{3000}' | '\u{feff}')
}
#[cfg(test)]
mod tests {
use super::*;
use crate::attachment::{AttachmentKind, AttachmentRef, StorageRef};
#[test]
fn canonical_metadata_and_bidirectional_modality_are_required_without_source_restriction() {
let attachment = AttachmentRef {
kind: AttachmentKind::Image,
name: "picture.png".into(),
mime_type: "image/png".into(),
bytes: 4,
storage_ref: StorageRef::SessionFile {
session_id: "session".into(),
relative_path: "artifact".into(),
},
};
let mut record = Artifact {
id: "artifact".into(),
session_id: "session".into(),
turn_id: "turn".into(),
created_at: 1,
name: attachment.name.clone(),
kind: ArtifactKind::Image,
size_bytes: attachment.bytes,
mime_type: Some(attachment.mime_type.clone()),
source: ArtifactSource::ToolResultProjection,
summary: None,
};
assert!(record.validate_attachment(&attachment).is_ok());
for changed in [
AttachmentRef {
name: "other.png".into(),
..attachment.clone()
},
AttachmentRef {
mime_type: "image/jpeg".into(),
..attachment.clone()
},
AttachmentRef {
bytes: 5,
..attachment.clone()
},
AttachmentRef {
kind: AttachmentKind::Other,
..attachment.clone()
},
] {
assert!(record.validate_attachment(&changed).is_err());
}
record.kind = ArtifactKind::File;
assert!(record.validate_attachment(&attachment).is_err());
let mut text = attachment;
text.mime_type = "text/plain".into();
text.kind = AttachmentKind::Other;
record.mime_type = Some(text.mime_type.clone());
assert!(record.validate_attachment(&text).is_ok());
for kind in [ArtifactKind::Image, ArtifactKind::Pdf] {
record.kind = kind;
assert!(record.validate_attachment(&text).is_err());
}
}
#[test]
fn names_are_complete_and_idempotent_with_javascript_whitespace() {
for (input, expected) in [
(" ../unsafe:name?.txt ".into(), "unsafe-name-.txt".into()),
("-.gitignore".into(), "gitignore".into()),
("\u{feff} . a\u{feff}".into(), "a".into()),
("\u{0085}a\u{0085}".into(), "\u{0085}a\u{0085}".into()),
(
format!("{}😀", "a".repeat(119)),
format!("{}😀", "a".repeat(119)),
),
(". -- ".into(), "artifact".into()),
] {
assert_eq!(normalize_name(&input), expected);
assert_eq!(normalize_name(&expected), expected);
}
}
}