Compare commits
10 Commits
feature/v0
...
feature/v0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a91ceea0ed | ||
|
|
4c0a289acb | ||
|
|
03559f81ea | ||
|
|
fe8eeb97c9 | ||
|
|
8ec616be5d | ||
|
|
bd323d8b1b | ||
|
|
db0ab1d82e | ||
|
|
68c6da4d67 | ||
|
|
bccd113f55 | ||
|
|
6e73c5e6a1 |
@@ -3,7 +3,7 @@
|
||||
//! (`commands/org.rs`). Centralizing it keeps the two surfaces from drifting.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::path::PathBuf;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use zeroize::Zeroizing;
|
||||
@@ -255,23 +255,39 @@ pub(crate) fn build_totp(
|
||||
})))
|
||||
}
|
||||
|
||||
/// Read a file and encrypt it as an attachment under `key`, deriving its display
|
||||
/// metadata. The plaintext is held in a `Zeroizing` buffer so it is wiped after
|
||||
/// encryption. Returns the encrypted blob plus (filename, mime_type, size).
|
||||
pub(crate) fn encrypt_document_file(
|
||||
path: &Path,
|
||||
key: &Zeroizing<[u8; 32]>,
|
||||
max_bytes: u64,
|
||||
) -> Result<(EncryptedAttachment, String, String, u64)> {
|
||||
use relicario_core::encrypt_attachment;
|
||||
let bytes = Zeroizing::new(
|
||||
std::fs::read(path).with_context(|| format!("failed to read {}", path.display()))?,
|
||||
);
|
||||
let enc = encrypt_attachment(&bytes, key, max_bytes)?;
|
||||
let filename = path
|
||||
.file_name()
|
||||
.ok_or_else(|| anyhow::anyhow!("file path has no filename: {}", path.display()))?
|
||||
.to_string_lossy()
|
||||
.into_owned();
|
||||
let mime_type = crate::parse::guess_mime(&filename);
|
||||
Ok((enc, filename, mime_type, bytes.len() as u64))
|
||||
}
|
||||
|
||||
pub(crate) fn build_document(
|
||||
title: String, file: PathBuf, key: &Zeroizing<[u8; 32]>, max_bytes: u64,
|
||||
) -> Result<(Item, EncryptedAttachment)> {
|
||||
use relicario_core::item_types::DocumentCore;
|
||||
use relicario_core::{encrypt_attachment, AttachmentRef};
|
||||
let bytes = std::fs::read(&file).with_context(|| format!("failed to read {}", file.display()))?;
|
||||
let enc = encrypt_attachment(&bytes, key, max_bytes)?;
|
||||
let filename = file.file_name()
|
||||
.ok_or_else(|| anyhow::anyhow!("file path has no filename: {}", file.display()))?
|
||||
.to_string_lossy().into_owned();
|
||||
let mime_type = crate::parse::guess_mime(&filename);
|
||||
let primary_attachment = enc.id.clone();
|
||||
use relicario_core::AttachmentRef;
|
||||
let (enc, filename, mime_type, size) = encrypt_document_file(&file, key, max_bytes)?;
|
||||
let mut item = Item::new(title, ItemCore::Document(DocumentCore {
|
||||
filename: filename.clone(), mime_type: mime_type.clone(), primary_attachment: primary_attachment.clone(),
|
||||
filename: filename.clone(), mime_type: mime_type.clone(), primary_attachment: enc.id.clone(),
|
||||
}));
|
||||
item.attachments.push(AttachmentRef {
|
||||
id: primary_attachment, filename, mime_type, size: bytes.len() as u64, created: item.created,
|
||||
id: enc.id.clone(), filename, mime_type, size, created: item.created,
|
||||
});
|
||||
Ok((item, enc))
|
||||
}
|
||||
|
||||
@@ -793,7 +793,7 @@ pub enum OrgAddKind {
|
||||
digits: u8,
|
||||
algorithm: String,
|
||||
},
|
||||
// Document is added later by Dev-C.
|
||||
Document { title: String, file: std::path::PathBuf },
|
||||
}
|
||||
|
||||
fn build_org_item(kind: OrgAddKind) -> Result<Item> {
|
||||
@@ -816,6 +816,7 @@ fn build_org_item(kind: OrgAddKind) -> Result<Item> {
|
||||
OrgAddKind::Totp { title, issuer, label, secret, secret_stdin, period, digits, algorithm } => {
|
||||
ib::build_totp(title, issuer, label, secret, secret_stdin, period, digits, &algorithm)
|
||||
}
|
||||
OrgAddKind::Document { .. } => unreachable!("Document handled in run_add before build_org_item"),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -833,7 +834,16 @@ pub fn run_add(dir: &Path, collection: &str, kind: OrgAddKind, tags: Vec<String>
|
||||
// …and the caller must hold a grant for it.
|
||||
UnlockedOrgVault::ensure_grant(&caller, collection)?;
|
||||
|
||||
let mut item = build_org_item(kind)?;
|
||||
// Build the item; Document additionally yields an encrypted attachment to persist.
|
||||
let (mut item, attachment_rel): (relicario_core::Item, Option<String>) = match kind {
|
||||
OrgAddKind::Document { title, file } => {
|
||||
let (item, enc) = ib::build_document(
|
||||
title, file, vault.key(), crate::org_session::DEFAULT_ORG_ATTACHMENT_MAX_BYTES)?;
|
||||
let rel = vault.save_attachment(collection, &item.id, &enc)?;
|
||||
(item, Some(rel))
|
||||
}
|
||||
other => (build_org_item(other)?, None),
|
||||
};
|
||||
item.tags = tags;
|
||||
let item_rel = vault.save_item(collection, &item)?;
|
||||
|
||||
@@ -855,11 +865,11 @@ pub fn run_add(dir: &Path, collection: &str, kind: OrgAddKind, tags: Vec<String>
|
||||
collection,
|
||||
item.id.as_str()
|
||||
);
|
||||
crate::org_session::org_git_run(
|
||||
&vault.root,
|
||||
&["add", &item_rel, "manifest.enc"],
|
||||
"org add: git add",
|
||||
)?;
|
||||
let mut add_args: Vec<&str> = vec!["add", &item_rel, "manifest.enc"];
|
||||
if let Some(ref rel) = attachment_rel {
|
||||
add_args.insert(1, rel);
|
||||
}
|
||||
crate::org_session::org_git_run(&vault.root, &add_args, "org add: git add")?;
|
||||
crate::org_session::org_git_run(&vault.root, &["commit", "-m", &commit_msg], "org add: git commit")?;
|
||||
|
||||
println!("Added {} ({}) to `{}`", item.title, item.id.as_str(), collection);
|
||||
@@ -991,7 +1001,7 @@ fn resolve_org_query<'a>(
|
||||
}
|
||||
}
|
||||
|
||||
pub fn run_edit(dir: &Path, query: &str, totp_qr: Option<std::path::PathBuf>) -> Result<()> {
|
||||
pub fn run_edit(dir: &Path, query: &str, totp_qr: Option<std::path::PathBuf>, file: Option<std::path::PathBuf>) -> Result<()> {
|
||||
use relicario_core::time::now_unix;
|
||||
use relicario_core::ItemCore;
|
||||
|
||||
@@ -1005,6 +1015,9 @@ pub fn run_edit(dir: &Path, query: &str, totp_qr: Option<std::path::PathBuf>) ->
|
||||
crate::org_session::UnlockedOrgVault::ensure_grant(&caller, &collection)?;
|
||||
|
||||
let mut item = vault.load_item(&collection, &id)?;
|
||||
if file.is_some() && !matches!(item.core, ItemCore::Document(_)) {
|
||||
anyhow::bail!("--file is only valid when editing a Document item");
|
||||
}
|
||||
eprintln!(
|
||||
"Editing: {} ({}) — leave a prompt blank to keep the current value.",
|
||||
item.title,
|
||||
@@ -1015,15 +1028,40 @@ pub fn run_edit(dir: &Path, query: &str, totp_qr: Option<std::path::PathBuf>) ->
|
||||
}
|
||||
|
||||
let history = &mut item.field_history;
|
||||
let mut doc_attachment_rel: Option<String> = None;
|
||||
let mut new_doc_attachments: Option<Vec<relicario_core::AttachmentRef>> = None;
|
||||
match &mut item.core {
|
||||
ItemCore::Login(l) => ib::edit_login(l, history, totp_qr)?,
|
||||
ItemCore::SecureNote(n) => ib::edit_secure_note(n, history)?,
|
||||
ItemCore::Identity(i) => ib::edit_identity(i)?,
|
||||
ItemCore::Card(c) => ib::edit_card(c, history)?,
|
||||
ItemCore::Key(k) => ib::edit_key(k, history)?,
|
||||
ItemCore::Document(_) => ib::edit_document_message(),
|
||||
ItemCore::Document(d) => {
|
||||
if let Some(path) = &file {
|
||||
let (enc, filename, mime_type, size) = ib::encrypt_document_file(
|
||||
path, vault.key(), crate::org_session::DEFAULT_ORG_ATTACHMENT_MAX_BYTES)?;
|
||||
vault.remove_item_attachments(&collection, &id)?;
|
||||
let rel = vault.save_attachment(&collection, &id, &enc)?;
|
||||
d.filename = filename.clone();
|
||||
d.mime_type = mime_type.clone();
|
||||
d.primary_attachment = enc.id.clone();
|
||||
new_doc_attachments = Some(vec![relicario_core::AttachmentRef {
|
||||
id: enc.id,
|
||||
filename,
|
||||
mime_type,
|
||||
size,
|
||||
created: now_unix(),
|
||||
}]);
|
||||
doc_attachment_rel = Some(rel);
|
||||
} else {
|
||||
ib::edit_document_message();
|
||||
}
|
||||
}
|
||||
ItemCore::Totp(t) => ib::edit_totp(t, history)?,
|
||||
}
|
||||
if let Some(atts) = new_doc_attachments {
|
||||
item.attachments = atts;
|
||||
}
|
||||
|
||||
item.modified = now_unix();
|
||||
let item_rel = vault.save_item(&collection, &item)?;
|
||||
@@ -1043,7 +1081,13 @@ pub fn run_edit(dir: &Path, query: &str, totp_qr: Option<std::path::PathBuf>) ->
|
||||
collection,
|
||||
item.id.as_str()
|
||||
);
|
||||
crate::org_session::org_git_run(&vault.root, &["add", &item_rel, "manifest.enc"], "org edit: git add")?;
|
||||
let mut add_args: Vec<&str> = vec!["add", &item_rel, "manifest.enc"];
|
||||
let att_dir_rel;
|
||||
if doc_attachment_rel.is_some() {
|
||||
att_dir_rel = format!("attachments/{}/{}", collection, id.as_str());
|
||||
add_args.push(&att_dir_rel);
|
||||
}
|
||||
crate::org_session::org_git_run(&vault.root, &add_args, "org edit: git add")?;
|
||||
crate::org_session::org_git_run(&vault.root, &["commit", "-m", &commit_msg], "org edit: git commit")?;
|
||||
println!("Updated {} ({}) in `{}`", item.title, item.id.as_str(), collection);
|
||||
Ok(())
|
||||
@@ -1119,12 +1163,14 @@ pub fn run_purge(dir: &Path, query: &str) -> Result<()> {
|
||||
|
||||
// Remove the blob from disk, drop the manifest entry, stage with git rm.
|
||||
vault.remove_item(&collection, &id)?;
|
||||
vault.remove_item_attachments(&collection, &id)?;
|
||||
let mut manifest = vault.load_manifest()?;
|
||||
manifest.entries.retain(|e| e.id != id);
|
||||
vault.save_manifest(&manifest)?;
|
||||
|
||||
let item_rel = format!("items/{}/{}.enc", collection, id.as_str());
|
||||
crate::helpers::git_rm(&vault.root, &[item_rel], "org purge: git rm")?;
|
||||
let att_dir_rel = format!("attachments/{}/{}", collection, id.as_str());
|
||||
crate::helpers::git_rm(&vault.root, &[item_rel, att_dir_rel], "org purge: git rm")?;
|
||||
crate::org_session::org_git_run(&vault.root, &["add", "manifest.enc"], "org purge: git add manifest")?;
|
||||
|
||||
let commit_msg = format!(
|
||||
|
||||
@@ -541,6 +541,8 @@ pub(crate) enum OrgCommands {
|
||||
query: String,
|
||||
/// Replace the login TOTP secret from a QR image.
|
||||
#[arg(long)] totp_qr: Option<std::path::PathBuf>,
|
||||
/// Replace a Document item's attachment file.
|
||||
#[arg(long)] file: Option<std::path::PathBuf>,
|
||||
},
|
||||
/// Soft-delete an org item (reversible via `org restore`).
|
||||
Rm { query: String },
|
||||
@@ -614,6 +616,13 @@ pub(crate) enum OrgAddKind {
|
||||
#[arg(long, value_delimiter = ',')] tags: Vec<String>,
|
||||
#[arg(long)] secret_stdin: bool,
|
||||
},
|
||||
/// A document (file payload encrypted into a collection-scoped attachment).
|
||||
Document {
|
||||
#[arg(long)] collection: String,
|
||||
#[arg(long)] title: String,
|
||||
#[arg(long)] file: std::path::PathBuf,
|
||||
#[arg(long, value_delimiter = ',')] tags: Vec<String>,
|
||||
},
|
||||
}
|
||||
|
||||
fn main() -> Result<()> {
|
||||
@@ -737,6 +746,11 @@ fn main() -> Result<()> {
|
||||
commands::org::OrgAddKind::Totp { title, issuer, label, secret, secret_stdin, period, digits, algorithm },
|
||||
tags,
|
||||
),
|
||||
OrgAddKind::Document { collection, title, file, tags } => (
|
||||
collection,
|
||||
commands::org::OrgAddKind::Document { title, file },
|
||||
tags,
|
||||
),
|
||||
};
|
||||
commands::org::run_add(&d, &collection, add_kind, tags)?;
|
||||
}
|
||||
@@ -748,9 +762,9 @@ fn main() -> Result<()> {
|
||||
let d = crate::org_session::org_dir(dir_path)?;
|
||||
commands::org::run_list(&d, trashed)?;
|
||||
}
|
||||
OrgCommands::Edit { query, totp_qr } => {
|
||||
OrgCommands::Edit { query, totp_qr, file } => {
|
||||
let d = crate::org_session::org_dir(dir_path)?;
|
||||
commands::org::run_edit(&d, &query, totp_qr)?;
|
||||
commands::org::run_edit(&d, &query, totp_qr, file)?;
|
||||
}
|
||||
OrgCommands::Rm { query } => {
|
||||
let d = crate::org_session::org_dir(dir_path)?;
|
||||
|
||||
@@ -9,9 +9,16 @@ use zeroize::Zeroizing;
|
||||
|
||||
use relicario_core::{
|
||||
decrypt_item, decrypt_org_manifest, encrypt_item, encrypt_org_manifest,
|
||||
Item, ItemId, MemberId, OrgCollections, OrgManifest, OrgMember, OrgMembers, OrgMeta,
|
||||
AttachmentId, EncryptedAttachment, Item, ItemId, MemberId, OrgCollections, OrgManifest,
|
||||
OrgMember, OrgMembers, OrgMeta,
|
||||
};
|
||||
|
||||
/// Default per-attachment cap for org vaults. Org vaults have no settings.enc,
|
||||
/// so this mirrors the personal-vault default
|
||||
/// `AttachmentCaps::per_attachment_max_bytes` at
|
||||
/// crates/relicario-core/src/settings.rs:116.
|
||||
pub const DEFAULT_ORG_ATTACHMENT_MAX_BYTES: u64 = 10 * 1024 * 1024;
|
||||
|
||||
pub struct UnlockedOrgVault {
|
||||
pub root: PathBuf,
|
||||
pub org_key: Zeroizing<[u8; 32]>,
|
||||
@@ -115,6 +122,40 @@ impl UnlockedOrgVault {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn attachment_path(&self, collection_slug: &str, item_id: &ItemId, att_id: &AttachmentId) -> PathBuf {
|
||||
self.root.join("attachments").join(collection_slug)
|
||||
.join(item_id.as_str()).join(format!("{}.enc", att_id.as_str()))
|
||||
}
|
||||
|
||||
/// Encrypt-already-done blob: persist it and return the repo-relative path for git staging.
|
||||
pub fn save_attachment(&self, collection_slug: &str, item_id: &ItemId, enc: &EncryptedAttachment) -> Result<String> {
|
||||
let path = self.attachment_path(collection_slug, item_id, &enc.id);
|
||||
if let Some(parent) = path.parent() {
|
||||
fs::create_dir_all(parent).with_context(|| format!("create {}", parent.display()))?;
|
||||
}
|
||||
atomic_write(&path, &enc.bytes)?;
|
||||
Ok(format!("attachments/{}/{}/{}.enc", collection_slug, item_id.as_str(), enc.id.as_str()))
|
||||
}
|
||||
|
||||
// Retained for a future `org document read/extract` command (mirrors `org_meta_path` convention).
|
||||
#[allow(dead_code)]
|
||||
pub fn load_attachment(&self, collection_slug: &str, item_id: &ItemId, att_id: &AttachmentId) -> Result<Zeroizing<Vec<u8>>> {
|
||||
let path = self.attachment_path(collection_slug, item_id, att_id);
|
||||
let bytes = fs::read(&path).with_context(|| format!("read attachment {}", path.display()))?;
|
||||
Ok(relicario_core::decrypt_attachment(&bytes, &self.org_key)?)
|
||||
}
|
||||
|
||||
/// Remove an item's whole attachment directory. Missing dir is NOT an error
|
||||
/// (mirrors `remove_item`'s NotFound-tolerant behavior, for partial-write recovery).
|
||||
pub fn remove_item_attachments(&self, collection_slug: &str, item_id: &ItemId) -> Result<()> {
|
||||
let dir = self.root.join("attachments").join(collection_slug).join(item_id.as_str());
|
||||
match fs::remove_dir_all(&dir) {
|
||||
Ok(()) => Ok(()),
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
|
||||
Err(e) => Err(anyhow::Error::from(e).context(format!("remove {}", dir.display()))),
|
||||
}
|
||||
}
|
||||
|
||||
/// Bail unless `member` has `slug` in their collection grants. The slug
|
||||
/// existence check is done separately by the caller against collections.json.
|
||||
pub fn ensure_grant(member: &OrgMember, slug: &str) -> Result<()> {
|
||||
@@ -292,6 +333,22 @@ mod tests {
|
||||
assert_eq!(loaded.entries.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn attachment_round_trip_collection_scoped() {
|
||||
use relicario_core::encrypt_attachment;
|
||||
let key = Zeroizing::new([7u8; 32]);
|
||||
let (dir, vault) = make_vault(key);
|
||||
let _ = dir; // keep tempdir alive
|
||||
let item_id = ItemId::new();
|
||||
let enc = encrypt_attachment(b"hello world", &vault.org_key, DEFAULT_ORG_ATTACHMENT_MAX_BYTES).unwrap();
|
||||
let rel = vault.save_attachment("eng", &item_id, &enc).unwrap();
|
||||
assert_eq!(rel, format!("attachments/eng/{}/{}.enc", item_id.as_str(), enc.id.as_str()));
|
||||
let got = vault.load_attachment("eng", &item_id, &enc.id).unwrap();
|
||||
assert_eq!(got.as_slice(), b"hello world");
|
||||
vault.remove_item_attachments("eng", &item_id).unwrap();
|
||||
assert!(vault.load_attachment("eng", &item_id, &enc.id).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn save_and_load_members() {
|
||||
let key = Zeroizing::new([0u8; 32]);
|
||||
|
||||
@@ -456,3 +456,132 @@ fn org_edit_key_replaces_material_and_reveals_with_show() {
|
||||
assert!(shown.contains("NEW-MATERIAL-bbbb"), "replaced material not revealed with --show: {shown}");
|
||||
assert!(!shown.contains("OLD-MATERIAL"), "old material still present after replace: {shown}");
|
||||
}
|
||||
|
||||
// --- v0.8.1 org Document tests -----------------------------------------------
|
||||
|
||||
/// `git status --porcelain` output for the org repo (trimmed). Empty-of-`attachments/`
|
||||
/// proves every attachment add/remove was staged into the signed commit.
|
||||
fn git_porcelain(repo: &str) -> String {
|
||||
let out = std::process::Command::new("git")
|
||||
.args(["-C", repo, "status", "--porcelain"])
|
||||
.output()
|
||||
.unwrap();
|
||||
String::from_utf8_lossy(&out.stdout).trim().to_string()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn org_add_document_stores_collection_scoped_attachment() {
|
||||
let f = OrgFixture::new();
|
||||
f.create_collection_and_grant("eng");
|
||||
let srcdir = TempDir::new().unwrap();
|
||||
let src = srcdir.path().join("note.txt");
|
||||
std::fs::write(&src, b"secret memo").unwrap();
|
||||
|
||||
let out = f.run(&["org", "add", "document", "--collection", "eng",
|
||||
"--title", "Q3 Memo", "--file", src.to_str().unwrap()]);
|
||||
assert!(out.status.success(), "add doc: {}", String::from_utf8_lossy(&out.stderr));
|
||||
|
||||
// Encrypted blob at attachments/eng/<item-id>/<att-id>.enc (3 segments).
|
||||
let att_eng = f.vault_path().join("attachments").join("eng");
|
||||
assert!(att_eng.exists(), "attachment dir missing");
|
||||
let item_dirs: Vec<_> = std::fs::read_dir(&att_eng).unwrap().map(|e| e.unwrap().path()).collect();
|
||||
assert_eq!(item_dirs.len(), 1, "expected exactly one item attachment dir");
|
||||
let blobs: Vec<_> = std::fs::read_dir(&item_dirs[0]).unwrap().map(|e| e.unwrap().path()).collect();
|
||||
assert_eq!(blobs.len(), 1, "expected exactly one attachment blob");
|
||||
assert_eq!(blobs[0].extension().and_then(|e| e.to_str()), Some("enc"), "blob must be .enc");
|
||||
|
||||
let got = f.run(&["org", "get", "Q3 Memo"]);
|
||||
let stdout = String::from_utf8_lossy(&got.stdout);
|
||||
assert!(stdout.contains("Filename: note.txt"), "get missing filename: {stdout}");
|
||||
// Staging proof: nothing attachment-related left uncommitted.
|
||||
assert!(!git_porcelain(f.vault_str()).contains("attachments/"), "unstaged attachment after add");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn org_purge_document_removes_attachment_dir() {
|
||||
let f = OrgFixture::new();
|
||||
f.create_collection_and_grant("eng");
|
||||
let srcdir = TempDir::new().unwrap();
|
||||
let src = srcdir.path().join("d.bin");
|
||||
std::fs::write(&src, b"bytes").unwrap();
|
||||
assert!(f.run(&["org", "add", "document", "--collection", "eng",
|
||||
"--title", "Doc", "--file", src.to_str().unwrap()]).status.success());
|
||||
|
||||
let att_eng = f.vault_path().join("attachments").join("eng");
|
||||
assert!(std::fs::read_dir(&att_eng).unwrap().next().is_some(), "attachment must exist after add");
|
||||
|
||||
assert!(f.run(&["org", "rm", "Doc"]).status.success(), "rm");
|
||||
let out = f.run(&["org", "purge", "Doc"]);
|
||||
assert!(out.status.success(), "purge: {}", String::from_utf8_lossy(&out.stderr));
|
||||
|
||||
let empty = !att_eng.exists() || std::fs::read_dir(&att_eng).unwrap().next().is_none();
|
||||
assert!(empty, "attachment dir should be gone after purge");
|
||||
assert!(!git_porcelain(f.vault_str()).contains("attachments/"), "unstaged attachment removal after purge");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn org_edit_document_replaces_attachment_and_stages_cleanly() {
|
||||
let f = OrgFixture::new();
|
||||
f.create_collection_and_grant("eng");
|
||||
let srcdir = TempDir::new().unwrap();
|
||||
let a = srcdir.path().join("a.txt");
|
||||
std::fs::write(&a, b"version A").unwrap();
|
||||
assert!(f.run(&["org", "add", "document", "--collection", "eng",
|
||||
"--title", "Spec", "--file", a.to_str().unwrap()]).status.success());
|
||||
|
||||
let b = srcdir.path().join("b.md");
|
||||
std::fs::write(&b, b"version B has different content").unwrap();
|
||||
let out = f.run(&["org", "edit", "Spec", "--file", b.to_str().unwrap()]);
|
||||
assert!(out.status.success(), "edit --file: {}", String::from_utf8_lossy(&out.stderr));
|
||||
|
||||
let got = String::from_utf8_lossy(&f.run(&["org", "get", "Spec"]).stdout).to_string();
|
||||
assert!(got.contains("Filename: b.md"), "get should show new filename: {got}");
|
||||
assert!(!got.contains("a.txt"), "old filename should be gone: {got}");
|
||||
|
||||
// Old blob replaced, not accumulated: exactly one blob remains.
|
||||
let att_eng = f.vault_path().join("attachments").join("eng");
|
||||
let item_dirs: Vec<_> = std::fs::read_dir(&att_eng).unwrap().map(|e| e.unwrap().path()).collect();
|
||||
assert_eq!(item_dirs.len(), 1, "one item attachment dir");
|
||||
let blobs = std::fs::read_dir(&item_dirs[0]).unwrap().count();
|
||||
assert_eq!(blobs, 1, "old blob must be replaced, not accumulated");
|
||||
|
||||
// The key staging proof: no orphaned old blob / unstaged new blob.
|
||||
assert!(!git_porcelain(f.vault_str()).contains("attachments/"),
|
||||
"edit-replace left attachment changes unstaged (incomplete git add)");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn org_edit_file_on_non_document_is_rejected() {
|
||||
let f = OrgFixture::new();
|
||||
f.create_collection_and_grant("eng");
|
||||
assert!(f.run(&["org", "add", "login", "--collection", "eng",
|
||||
"--title", "Site", "--password", "p"]).status.success());
|
||||
let srcdir = TempDir::new().unwrap();
|
||||
let x = srcdir.path().join("x.txt");
|
||||
std::fs::write(&x, b"nope").unwrap();
|
||||
|
||||
let out = f.run(&["org", "edit", "Site", "--file", x.to_str().unwrap()]);
|
||||
assert!(!out.status.success(), "--file on a Login must be rejected");
|
||||
let stderr = String::from_utf8_lossy(&out.stderr);
|
||||
assert!(stderr.contains("--file is only valid"), "unexpected error: {stderr}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn org_add_document_into_ungranted_collection_is_denied() {
|
||||
let f = OrgFixture::new();
|
||||
// Collection exists but the owner is NOT granted.
|
||||
assert!(f.run(&["org", "create-collection", "secret", "--name", "Secret"]).status.success(),
|
||||
"create-collection");
|
||||
let srcdir = TempDir::new().unwrap();
|
||||
let src = srcdir.path().join("f.txt");
|
||||
std::fs::write(&src, b"data").unwrap();
|
||||
|
||||
let out = f.run(&["org", "add", "document", "--collection", "secret",
|
||||
"--title", "X", "--file", src.to_str().unwrap()]);
|
||||
assert!(!out.status.success(), "ungranted document add must fail");
|
||||
let stderr = String::from_utf8_lossy(&out.stderr);
|
||||
assert!(stderr.contains("access denied") || stderr.contains("grant"), "unexpected error: {stderr}");
|
||||
// Grant is enforced before any attachment is written.
|
||||
assert!(!f.vault_path().join("attachments").join("secret").exists(),
|
||||
"no attachment dir should exist on a denied add");
|
||||
}
|
||||
|
||||
@@ -82,6 +82,7 @@ collections.json # collection definitions
|
||||
keys/<member-id>.enc # org master key wrapped to that member's device key
|
||||
manifest.enc # OrgManifest (schema_version 1, per-member-filtered)
|
||||
items/<collection-slug>/<item-id>.enc # collection-scoped item blobs
|
||||
attachments/<collection-slug>/<item-id>/<att-id>.enc # Document attachment blobs (collection-scoped)
|
||||
```
|
||||
|
||||
### `org.json` — OrgMeta
|
||||
@@ -123,7 +124,13 @@ Standard `.enc` blob (see **Encrypted blob** above), encrypted under the org mas
|
||||
|
||||
These blobs are written and read by the `relicario org` item commands (`org add` / `get` / `list` / `edit` / `rm` / `restore` / `purge`), all collection-scoped and grant-enforced. `org add` currently creates Login / SecureNote / Identity items; `get` / `list` display any item type present.
|
||||
|
||||
**TODO (extension follow-up):** extension UI for browsing and editing org vault items. **Deferred:** `org add` / `edit` parity for Card / Key / Document / Totp item types.
|
||||
### `attachments/<collection-slug>/<item-id>/<att-id>.enc`
|
||||
|
||||
Standard `.enc` blob (see **Encrypted blob** above), encrypted under the org master key — the encrypted file payload of a Document item. As with item blobs, the blob does **not** name its collection; the leading `<collection-slug>` path segment carries it, so the pre-receive hook (`relicario-server`, `classify_path`) authorizes the write by slug without decrypting — reusing the same grant + slug-existence check as the `items/` branch. The path is **exactly three segments** after `attachments/` (`<collection-slug>/<item-id>/<att-id>.enc`); the hook rejects any other shape (segment-count and `.`-free slug guards). `<att-id>` is the content-addressed `AttachmentId` (see **Item IDs and Field IDs** below).
|
||||
|
||||
Per-attachment size is capped at `DEFAULT_ORG_ATTACHMENT_MAX_BYTES = 10 * 1024 * 1024` (10 MiB) (`org_session.rs:24`), mirroring the personal-vault default `AttachmentCaps::per_attachment_max_bytes` (`settings.rs:116`). Org vaults have no `settings.enc`, so this cap is a fixed default rather than per-org configurable. Blobs are persisted / read / removed by `UnlockedOrgVault::save_attachment` / `load_attachment` / `remove_item_attachments` (`org_session.rs:137`, `:147`, `:156`). The storage primitives back the org **Document** item type; the `org add document` / Document-edit commands that produce these blobs land in v0.8.1 (see the item-type-parity note below).
|
||||
|
||||
**TODO (extension follow-up):** extension UI for browsing and editing org vault items. **Deferred:** `org add` / `edit` parity for Card / Key / Document / Totp item types (landing in v0.8.1; Document file payloads use the attachment layout above).
|
||||
|
||||
## Item IDs and Field IDs
|
||||
|
||||
|
||||
Reference in New Issue
Block a user