stashify 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
stash/__init__.py ADDED
@@ -0,0 +1,5 @@
1
+ """Stash - Privacy-focused CLI storage system."""
2
+
3
+ __version__ = "0.1.0"
4
+ __author__ = "Stash Contributors"
5
+ __license__ = "MIT"
stash/cli/__init__.py ADDED
@@ -0,0 +1,5 @@
1
+ """Stash CLI package."""
2
+
3
+ from stash.cli.main import main
4
+
5
+ __all__ = ["main"]
@@ -0,0 +1,23 @@
1
+ """CLI commands package."""
2
+
3
+ from stash.cli.commands.get import get_commands
4
+ from stash.cli.commands.info import info_commands
5
+ from stash.cli.commands.init import init_commands
6
+ from stash.cli.commands.ls import ls_commands
7
+ from stash.cli.commands.provider import provider_commands
8
+ from stash.cli.commands.put import put_commands
9
+ from stash.cli.commands.rm import rm_commands
10
+ from stash.cli.commands.status import status_commands
11
+ from stash.cli.commands.verify import verify_commands
12
+
13
+ __all__ = [
14
+ "init_commands",
15
+ "provider_commands",
16
+ "put_commands",
17
+ "get_commands",
18
+ "ls_commands",
19
+ "info_commands",
20
+ "rm_commands",
21
+ "verify_commands",
22
+ "status_commands",
23
+ ]
@@ -0,0 +1,160 @@
1
+ """CLI command: get - Retrieve a file."""
2
+
3
+ import asyncio
4
+ from pathlib import Path
5
+
6
+ import click
7
+
8
+ from stash.cli.output import create_progress, format_size, print_error, print_info, print_success
9
+ from stash.core.crypto import CryptoEngine, EncryptionConfig
10
+ from stash.core.metadata import MetadataStore
11
+ from stash.providers import ProviderRegistry
12
+
13
+
14
+ @click.command()
15
+ @click.argument("file_id_or_name")
16
+ @click.option("--output", "-o", type=click.Path(path_type=Path), help="Output path (default: current directory)")
17
+ @click.option("--password", prompt=True, hide_input=True, help="Encryption password")
18
+ @click.option("--overwrite", is_flag=True, help="Overwrite existing file")
19
+ @click.pass_context
20
+ def get_cmd(ctx: click.Context, file_id_or_name: str, output: Path | None, password: str, overwrite: bool) -> None:
21
+ """Retrieve a file from Stash."""
22
+ asyncio.run(_get_async(file_id_or_name, ctx.obj["repo"], output, password, overwrite))
23
+
24
+
25
+ async def _get_async(
26
+ file_id_or_name: str,
27
+ repo_path: Path,
28
+ output: Path | None,
29
+ password: str,
30
+ overwrite: bool,
31
+ ) -> None:
32
+ repo = repo_path.resolve()
33
+ store = MetadataStore(repo)
34
+
35
+ file_id = _resolve_file_id(store, file_id_or_name)
36
+ if not file_id:
37
+ print_error(f"File not found: {file_id_or_name}")
38
+ return
39
+
40
+ manifest = store.load_manifest(file_id)
41
+
42
+ # Decrypt filename
43
+ crypto = CryptoEngine()
44
+ enc_config = EncryptionConfig(
45
+ algorithm=manifest.encryption.algorithm,
46
+ key_size=manifest.encryption.key_size,
47
+ nonce_size=manifest.encryption.nonce_size,
48
+ chunk_key_derivation=manifest.encryption.chunk_key_derivation,
49
+ )
50
+ file_key = None
51
+ try:
52
+ if manifest.encryption.file_key_wrapped:
53
+ from stash.core.crypto import FileKey
54
+ file_key = FileKey(
55
+ key=crypto.decrypt_file_key(
56
+ manifest.encryption.file_key_wrapped,
57
+ password,
58
+ enc_config
59
+ ).key,
60
+ salt=manifest.encryption.file_key_salt,
61
+ config=enc_config
62
+ )
63
+ else:
64
+ print_error("File key not wrapped - cannot decrypt")
65
+ return
66
+ except Exception as e:
67
+ print_error(f"Failed to decrypt file key: {e}")
68
+ return
69
+
70
+ # Decrypt filename
71
+ from stash.core.crypto import EncryptedChunk
72
+ encrypted_name_bytes = bytes.fromhex(manifest.encrypted_name)
73
+ encrypted_name_chunk = EncryptedChunk(
74
+ ciphertext=encrypted_name_bytes,
75
+ nonce=manifest.encrypted_name_nonce,
76
+ chunk_index=-1,
77
+ )
78
+ try:
79
+ decrypted_name = crypto.decrypt_chunk(encrypted_name_chunk, file_key).decode()
80
+ except Exception as e:
81
+ print_error(f"Failed to decrypt filename: {e}")
82
+ return
83
+
84
+ if output is None:
85
+ output_path = Path.cwd() / decrypted_name
86
+ elif output.is_dir():
87
+ output_path = output / decrypted_name
88
+ else:
89
+ output_path = output
90
+
91
+ if output_path.exists() and not overwrite:
92
+ print_error(f"File exists: {output_path}. Use --overwrite to replace.")
93
+ return
94
+
95
+ providers_config = {}
96
+ for chunk in manifest.chunks:
97
+ if chunk.provider not in providers_config:
98
+ config = store.get_provider_config(chunk.provider)
99
+ if config:
100
+ providers_config[chunk.provider] = config
101
+
102
+ provider_instances = {}
103
+ for name, config in providers_config.items():
104
+ instance = await ProviderRegistry.create(config.type, config)
105
+ provider_instances[name] = instance
106
+
107
+ output_path.parent.mkdir(parents=True, exist_ok=True)
108
+
109
+ progress = create_progress()
110
+ task = progress.add_task("Downloading", total=manifest.chunk_count)
111
+
112
+ with progress, output_path.open("wb") as f:
113
+ for chunk_info in sorted(manifest.chunks, key=lambda c: c.index):
114
+ provider = provider_instances.get(chunk_info.provider)
115
+ if not provider:
116
+ print_error(f"Provider not available: {chunk_info.provider}")
117
+ return
118
+
119
+ remote_ref = type('RemoteRef', (), {
120
+ 'provider': chunk_info.provider,
121
+ 'remote_id': chunk_info.remote_id,
122
+ 'metadata': chunk_info.metadata
123
+ })()
124
+
125
+ encrypted_data = await provider.download_chunk(remote_ref)
126
+
127
+ from stash.core.crypto import EncryptedChunk
128
+ encrypted_chunk = EncryptedChunk(
129
+ ciphertext=encrypted_data,
130
+ nonce=chunk_info.nonce,
131
+ chunk_index=chunk_info.index,
132
+ )
133
+
134
+ try:
135
+ decrypted = crypto.decrypt_chunk(encrypted_chunk, file_key)
136
+ f.write(decrypted)
137
+ progress.advance(task)
138
+ except Exception as e:
139
+ print_error(f"Decryption failed for chunk {chunk_info.index}: {e}")
140
+ return
141
+
142
+ for instance in provider_instances.values():
143
+ await instance.close()
144
+
145
+ print_success(f"Retrieved '{decrypted_name}' to {output_path}")
146
+ print_info(f"Size: {format_size(manifest.original_size)}")
147
+
148
+
149
+ def _resolve_file_id(store: MetadataStore, identifier: str) -> str | None:
150
+ """Resolve file ID or name to file ID."""
151
+ if store.file_exists(identifier):
152
+ return identifier
153
+ for fid in store.list_files():
154
+ manifest = store.load_manifest(fid)
155
+ if manifest.original_name == identifier:
156
+ return fid
157
+ return None
158
+
159
+
160
+ get_commands = get_cmd
@@ -0,0 +1,45 @@
1
+ """CLI command: info - Show file metadata."""
2
+
3
+
4
+ import click
5
+
6
+ from stash.cli.output import print_error, print_file_info
7
+ from stash.core.metadata import MetadataStore
8
+
9
+
10
+ @click.command()
11
+ @click.argument("file_id_or_name")
12
+ @click.pass_context
13
+ def info_cmd(ctx: click.Context, file_id_or_name: str) -> None:
14
+ """Show detailed file metadata."""
15
+ repo = ctx.obj["repo"].resolve()
16
+ store = MetadataStore(repo)
17
+
18
+ file_id = _resolve_file_id(store, file_id_or_name)
19
+ if not file_id:
20
+ print_error(f"File not found: {file_id_or_name}")
21
+ return
22
+
23
+ manifest = store.load_manifest(file_id)
24
+
25
+ providers = {}
26
+ for name in store.list_providers():
27
+ config = store.get_provider_config(name)
28
+ if config:
29
+ providers[name] = config
30
+
31
+ print_file_info(manifest, providers)
32
+
33
+
34
+ def _resolve_file_id(store: MetadataStore, identifier: str) -> str | None:
35
+ """Resolve file ID or name to file ID."""
36
+ if store.file_exists(identifier):
37
+ return identifier
38
+ for fid in store.list_files():
39
+ manifest = store.load_manifest(fid)
40
+ if manifest.original_name == identifier:
41
+ return fid
42
+ return None
43
+
44
+
45
+ info_commands = info_cmd
@@ -0,0 +1,34 @@
1
+ """CLI command: init - Initialize a new Stash repository."""
2
+
3
+
4
+ import click
5
+
6
+ from stash.cli.output import print_error, print_info, print_success
7
+ from stash.core.metadata import MetadataStore
8
+
9
+
10
+ @click.command()
11
+ @click.option("--force", "-f", is_flag=True, help="Overwrite existing repository")
12
+ @click.pass_context
13
+ def init_cmd(ctx: click.Context, force: bool) -> None:
14
+ """Initialize a new Stash repository."""
15
+ repo_path = ctx.obj["repo"]
16
+ metadata_dir = repo_path / ".stash" / "metadata"
17
+
18
+ if metadata_dir.exists() and not force:
19
+ print_error(f"Repository already exists at {repo_path}")
20
+ print_info("Use --force to overwrite")
21
+ return
22
+
23
+ store = MetadataStore(repo_path)
24
+ store.save_config({
25
+ "version": 1,
26
+ "created_at": __import__("time").time(),
27
+ "providers": {},
28
+ })
29
+
30
+ print_success(f"Initialized Stash repository at {repo_path}")
31
+ print_info("Add a provider with: stash provider add discord")
32
+
33
+
34
+ init_commands = init_cmd
@@ -0,0 +1,41 @@
1
+ """CLI command: ls - List stored files."""
2
+
3
+
4
+ import click
5
+
6
+ from stash.cli.output import format_size, format_timestamp, print_info, print_table
7
+ from stash.core.metadata import MetadataStore
8
+
9
+
10
+ @click.command()
11
+ @click.option("--long", "-l", is_flag=True, help="Show detailed information")
12
+ @click.pass_context
13
+ def ls_cmd(ctx: click.Context, long: bool) -> None:
14
+ """List stored files."""
15
+ repo = ctx.obj["repo"].resolve()
16
+ store = MetadataStore(repo)
17
+
18
+ files = store.list_files()
19
+ if not files:
20
+ print_info("No files stored")
21
+ return
22
+
23
+ if long:
24
+ rows = []
25
+ for fid in files:
26
+ manifest = store.load_manifest(fid)
27
+ rows.append([
28
+ fid[:16],
29
+ manifest.original_name,
30
+ format_size(manifest.original_size),
31
+ str(manifest.chunk_count),
32
+ format_timestamp(manifest.created_at),
33
+ ])
34
+ print_table("Stored Files", ["File ID", "Name", "Size", "Chunks", "Created"], rows)
35
+ else:
36
+ for fid in files:
37
+ manifest = store.load_manifest(fid)
38
+ print_info(f"{fid[:16]} {manifest.original_name} ({format_size(manifest.original_size)})")
39
+
40
+
41
+ ls_commands = ls_cmd
@@ -0,0 +1,124 @@
1
+ """CLI commands for provider management."""
2
+
3
+
4
+ import click
5
+
6
+ from stash.cli.output import confirm, print_error, print_info, print_success
7
+ from stash.core.metadata import MetadataStore
8
+ from stash.core.storage import ProviderConfig
9
+ from stash.providers import ProviderRegistry
10
+
11
+
12
+ @click.command()
13
+ @click.argument("name")
14
+ @click.option("--type", "provider_type", type=click.Choice(["discord", "telegram"]), help="Provider type")
15
+ @click.option("--token", prompt=True, hide_input=True, help="Bot token")
16
+ @click.option("--channel-id", help="Discord channel ID for storage")
17
+ @click.option("--chat-id", help="Telegram chat ID for storage")
18
+ @click.option("--is-bot/--is-user", default=True, help="Discord: token type (default: bot)")
19
+ @click.option("--max-concurrent", default=3, help="Max concurrent uploads")
20
+ @click.pass_context
21
+ def provider_add_cmd(
22
+ ctx: click.Context,
23
+ name: str,
24
+ provider_type: str | None,
25
+ token: str,
26
+ channel_id: str | None,
27
+ chat_id: str | None,
28
+ is_bot: bool,
29
+ max_concurrent: int,
30
+ ) -> None:
31
+ """Add a storage provider."""
32
+ repo_path = ctx.obj["repo"]
33
+ store = MetadataStore(repo_path)
34
+
35
+ if name in store.list_providers():
36
+ print_error(f"Provider '{name}' already exists")
37
+ return
38
+
39
+ if provider_type is None:
40
+ provider_type = name.lower()
41
+
42
+ if provider_type not in ProviderRegistry.list_providers():
43
+ print_error(f"Unknown provider type: {provider_type}")
44
+ print_info(f"Available types: {', '.join(ProviderRegistry.list_providers())}")
45
+ return
46
+
47
+ credentials: dict[str, str] = {"token": token}
48
+ if provider_type == "discord":
49
+ if not channel_id:
50
+ channel_id = click.prompt("Discord channel ID", type=str)
51
+ credentials["channel_id"] = channel_id
52
+ credentials["is_bot"] = str(is_bot).lower()
53
+ elif provider_type == "telegram":
54
+ if not chat_id:
55
+ chat_id = click.prompt("Telegram chat ID", type=str)
56
+ credentials["chat_id"] = chat_id
57
+
58
+ config = ProviderConfig(
59
+ name=name,
60
+ type=provider_type,
61
+ credentials=credentials,
62
+ settings={
63
+ "max_concurrent": str(max_concurrent),
64
+ },
65
+ )
66
+
67
+ store.set_provider_config(name, config)
68
+ print_success(f"Added provider '{name}' ({provider_type})")
69
+
70
+
71
+ @click.command(name="list")
72
+ @click.pass_context
73
+ def provider_list_cmd(ctx: click.Context) -> None:
74
+ """List configured providers."""
75
+ repo_path = ctx.obj["repo"]
76
+ store = MetadataStore(repo_path)
77
+
78
+ providers = store.list_providers()
79
+ if not providers:
80
+ print_info("No providers configured")
81
+ print_info("Add one with: stash provider add --type discord <name>")
82
+ return
83
+
84
+ from stash.cli.output import print_table
85
+ rows = []
86
+ for name in providers:
87
+ config = store.get_provider_config(name)
88
+ ptype = config.type if config else "unknown"
89
+ if ptype == "discord":
90
+ channel = config.credentials.get("channel_id", "unknown") if config else "unknown"
91
+ elif ptype == "telegram":
92
+ channel = config.credentials.get("chat_id", "unknown") if config else "unknown"
93
+ else:
94
+ channel = "unknown"
95
+ rows.append([name, ptype, channel])
96
+ print_table("Configured Providers", ["Name", "Type", "Channel/Chat ID"], rows)
97
+
98
+
99
+ @click.command()
100
+ @click.argument("name")
101
+ @click.option("--force", "-f", is_flag=True, help="Force removal")
102
+ @click.pass_context
103
+ def provider_remove_cmd(ctx: click.Context, name: str, force: bool) -> None:
104
+ """Remove a storage provider."""
105
+ repo_path = ctx.obj["repo"]
106
+ store = MetadataStore(repo_path)
107
+
108
+ if name not in store.list_providers():
109
+ print_error(f"Provider '{name}' not found")
110
+ return
111
+
112
+ if not force and not confirm(f"Remove provider '{name}'?"):
113
+ print_info("Cancelled")
114
+ return
115
+
116
+ store.remove_provider_config(name)
117
+ print_success(f"Removed provider '{name}'")
118
+
119
+
120
+ provider_commands = click.Group("provider", commands={
121
+ "add": provider_add_cmd,
122
+ "list": provider_list_cmd,
123
+ "remove": provider_remove_cmd,
124
+ })
@@ -0,0 +1,185 @@
1
+ """CLI command: put - Store a file."""
2
+
3
+ import asyncio
4
+ from pathlib import Path
5
+
6
+ import click
7
+
8
+ from stash.cli.output import confirm as confirm_prompt
9
+ from stash.cli.output import create_progress, format_size, print_error, print_info, print_success
10
+ from stash.core.chunking import ChunkConfig, Chunker
11
+ from stash.core.crypto import CryptoEngine
12
+ from stash.core.jobs import JobConfig
13
+ from stash.core.manifest import (
14
+ DistributionStrategy,
15
+ EncryptionInfo,
16
+ ManifestBuilder,
17
+ compute_checksum,
18
+ generate_file_id,
19
+ )
20
+ from stash.core.metadata import MetadataStore
21
+ from stash.providers import ProviderRegistry
22
+
23
+
24
+ @click.command()
25
+ @click.argument("file_path", type=click.Path(exists=True, path_type=Path))
26
+ @click.option("--provider", help="Specific provider to use (default: first available)")
27
+ @click.option("--chunk-size", type=int, help="Chunk size in bytes (default: provider limit)")
28
+ @click.option("--strategy", type=click.Choice(["single", "split", "balanced", "replicated"]), default="single", help="Distribution strategy")
29
+ @click.option("--password", prompt=True, hide_input=True, help="Encryption password")
30
+ @click.option("--confirm/--no-confirm", default=True, help="Confirm before upload")
31
+ @click.pass_context
32
+ def put_cmd(ctx: click.Context, file_path: Path, provider: str | None, chunk_size: int | None, strategy: str, password: str, confirm: bool) -> None:
33
+ """Store a file in Stash."""
34
+ asyncio.run(_put_async(file_path, ctx.obj["repo"], provider, chunk_size, strategy, password, confirm))
35
+
36
+
37
+ async def _put_async(
38
+ file_path: Path,
39
+ repo_path: Path,
40
+ provider_name: str | None,
41
+ chunk_size: int | None,
42
+ strategy: str,
43
+ password: str,
44
+ do_confirm: bool,
45
+ ) -> None:
46
+ repo = repo_path.resolve()
47
+ store = MetadataStore(repo)
48
+
49
+ providers = store.list_providers()
50
+ if not providers:
51
+ print_error("No providers configured. Run: stash provider add discord")
52
+ return
53
+
54
+ if provider_name:
55
+ if provider_name not in providers:
56
+ print_error(f"Provider '{provider_name}' not found")
57
+ return
58
+ provider_names = [provider_name]
59
+ else:
60
+ provider_names = providers
61
+
62
+ if not file_path.exists():
63
+ print_error(f"File not found: {file_path}")
64
+ return
65
+
66
+ file_size = file_path.stat().st_size
67
+ if file_size == 0:
68
+ print_error("Cannot store empty file")
69
+ return
70
+
71
+ if do_confirm and not confirm_prompt(f"Store '{file_path.name}' ({format_size(file_size)})?"):
72
+ print_info("Cancelled")
73
+ return
74
+
75
+ crypto = CryptoEngine()
76
+ file_key = crypto.generate_file_key()
77
+ wrapped_key = crypto.encrypt_file_key(file_key, password)
78
+
79
+ # Encrypt the filename
80
+ encrypted_name_chunk = crypto.encrypt_chunk(file_path.name.encode(), file_key, -1)
81
+ encrypted_name = encrypted_name_chunk.ciphertext.hex()
82
+ encrypted_name_nonce = encrypted_name_chunk.nonce
83
+
84
+ provider_configs = {}
85
+ for name in provider_names:
86
+ config = store.get_provider_config(name)
87
+ if not config:
88
+ print_error(f"Provider config not found: {name}")
89
+ return
90
+ provider_configs[name] = config
91
+
92
+ provider_instances = {}
93
+ for name, config in provider_configs.items():
94
+ instance = await ProviderRegistry.create(config.type, config)
95
+ provider_instances[name] = instance
96
+
97
+ limits = {name: p.get_limits() for name, p in provider_instances.items()}
98
+ max_chunk = min(l.max_chunk_size for l in limits.values())
99
+ effective_chunk_size = min(chunk_size or max_chunk, max_chunk)
100
+
101
+ chunker = Chunker(ChunkConfig(chunk_size=effective_chunk_size))
102
+ num_chunks = chunker.get_num_chunks(file_size)
103
+
104
+ dist_strategy = DistributionStrategy(strategy)
105
+
106
+ encryption_info = EncryptionInfo(
107
+ algorithm="AES-256-GCM",
108
+ key_size=32,
109
+ nonce_size=12,
110
+ chunk_key_derivation="HKDF-SHA256",
111
+ file_key_salt=file_key.salt,
112
+ file_key_wrapped=wrapped_key,
113
+ )
114
+
115
+ builder = ManifestBuilder(
116
+ file_id=generate_file_id(),
117
+ original_name=file_path.name,
118
+ encrypted_name=encrypted_name,
119
+ encrypted_name_nonce=encrypted_name_nonce,
120
+ original_size=file_size,
121
+ chunk_size=effective_chunk_size,
122
+ encryption=encryption_info,
123
+ strategy=dist_strategy,
124
+ )
125
+
126
+ print_info(f"Processing {num_chunks} chunks ({format_size(effective_chunk_size)} each)...")
127
+
128
+ JobConfig(max_workers=min(4, num_chunks))
129
+
130
+ semaphores = {name: asyncio.Semaphore(int(p.config.settings.get("max_concurrent", "3"))) for name, p in provider_instances.items()}
131
+
132
+ progress = create_progress()
133
+ task = progress.add_task("Uploading", total=num_chunks)
134
+
135
+ from stash.core.chunking import Chunk
136
+
137
+ async def upload_chunk(chunk: Chunk, provider_name: str) -> None:
138
+ async with semaphores[provider_name]:
139
+ # Use opaque identifier: file_id + chunk index (no filename)
140
+ remote_path = f"{builder.file_id}/chunk-{chunk.index:06d}"
141
+ remote_ref = await provider_instances[provider_name].upload_chunk(chunk, remote_path)
142
+ checksum = compute_checksum(chunk.data)
143
+ builder.add_chunk(
144
+ index=chunk.index,
145
+ size=chunk.size,
146
+ encrypted_size=len(remote_ref.metadata.get("size", "0")),
147
+ checksum=checksum,
148
+ provider=provider_name,
149
+ remote_id=remote_ref.remote_id,
150
+ nonce=encrypted.nonce,
151
+ metadata=remote_ref.metadata,
152
+ )
153
+ progress.advance(task)
154
+
155
+ with progress:
156
+ for chunk in chunker.chunk_file(file_path):
157
+ encrypted = crypto.encrypt_chunk(chunk.data, file_key, chunk.index)
158
+ encrypted_chunk = type(chunk)(
159
+ index=chunk.index,
160
+ data=encrypted.ciphertext,
161
+ offset=chunk.offset,
162
+ size=len(encrypted.ciphertext),
163
+ is_last=chunk.is_last,
164
+ )
165
+
166
+ if dist_strategy == DistributionStrategy.SINGLE:
167
+ target = provider_names[0]
168
+ elif dist_strategy == DistributionStrategy.SPLIT:
169
+ target = provider_names[chunk.index % len(provider_names)]
170
+ else:
171
+ target = provider_names[0]
172
+
173
+ await upload_chunk(encrypted_chunk, target)
174
+
175
+ manifest = builder.build()
176
+ store.save_manifest(manifest)
177
+
178
+ for instance in provider_instances.values():
179
+ await instance.close()
180
+
181
+ print_success(f"Stored '{file_path.name}' as {manifest.file_id}")
182
+ print_info(f"Chunks: {manifest.chunk_count}, Size: {format_size(manifest.original_size)}")
183
+
184
+
185
+ put_commands = put_cmd